From 9a5fe6bdf59d5d8feeedb2f24d9f5c9c87c9df19 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:55:57 +1000 Subject: [PATCH 01/11] chore: Update benchmarks and fix tests --- src/Polyfills/ArgumentExceptionHelper.cs | 5 +- .../ArgumentOutOfRangeExceptionHelper.cs | 4 +- .../ObjectDisposedExceptionHelper.cs | 5 +- src/Polyfills/TaskCompletionSource.cs | 6 +- .../InvalidOperationExceptionHelper.cs | 33 +-- .../Internal/ScheduledDrainState.cs | 29 +- .../Internal/SequencerPeriodicExtensions.cs | 9 +- .../ObservableSubscriptionExtensions.cs | 14 +- .../Operators/AsSignalObservable.cs | 8 +- .../Operators/BufferUntilIdleObservable.cs | 5 +- .../Operators/ConflateObservable.cs | 23 +- .../Operators/DebounceImmediateObservable.cs | 2 +- .../Operators/DebounceUntilObservable.cs | 5 +- .../Operators/DetectStaleObservable.cs | 11 +- .../Operators/ForEachObservable.cs | 7 +- .../Operators/FromArrayObservable.cs | 7 +- .../Operators/HeartbeatObservable.cs | 4 +- .../Operators/ObserveOnIfObservable.cs | 2 +- .../Operators/ObserveOnObservable.cs | 9 +- .../Operators/RetryBackoffPolicy.cs | 5 +- .../Operators/RetryWithDelayObservable.cs | 6 +- .../Operators/RunAllObservable.cs | 24 +- .../Operators/ScheduleConfig.cs | 4 +- .../Operators/ScheduledSourceObservable.cs | 30 +-- .../Operators/ScheduledValueObservable.cs | 21 +- .../Operators/SyncTimerObservable.cs | 37 +-- .../Operators/ThrottleDistinctObservable.cs | 16 +- .../Operators/ThrottleObservable.cs | 9 +- .../Operators/ThrottleUntilTrueObservable.cs | 11 +- .../Operators/UsingActionObservable.cs | 9 +- .../Operators/UsingFuncObservable.cs | 8 +- .../Operators/WhileObservable.cs | 9 +- .../ReactiveExtensions.cs | 20 +- src/Primitives.Shared/Advanced/AfterSignal.cs | 6 +- .../Advanced/AfterSubscription.cs | 4 +- .../Advanced/AsyncSubscriptionLifetime.cs | 6 +- .../Advanced/AutoShareSignal{T}.cs | 7 +- .../Advanced/BufferSignal{T}.cs | 4 +- .../Advanced/CatchSignal{T}.cs | 77 +++--- .../Advanced/CollectSignal{T}.cs | 4 +- .../Advanced/CollectWitness{T}.cs | 10 +- .../Advanced/CreateSafeSignal{T}.cs | 31 +-- .../Advanced/CreateSignal{T,TState}.cs | 62 ++--- .../Advanced/CreateSignal{T}.cs | 32 +-- .../Advanced/CreateSink{T}.cs | 9 +- .../Advanced/DeferSignal{T}.cs | 26 +- .../Advanced/EmitIfQuietSignal{T}.cs | 4 +- .../Advanced/EmitIfQuietWitness{T}.cs | 10 +- .../Advanced/EmptySignal{T}.cs | 26 +- src/Primitives.Shared/Advanced/EverySignal.cs | 24 +- .../Advanced/ExpireCoordinator{T}.cs | 44 ++- .../Advanced/ExpireSignal{T}.cs | 6 +- .../Advanced/FinallySignal{T}.cs | 60 ++--- .../Advanced/FromAsyncSubscription{T}.cs | 10 +- ...Signal{TEventHandler,TCallback,TResult}.cs | 6 +- .../Advanced/GuardedWitness{T}.cs | 7 +- .../Advanced/ImmutableReturnRxVoidSignal.cs | 22 +- .../Advanced/IsEmptySignal{T}.cs | 9 +- .../Advanced/IsEmptyWitness{T}.cs | 2 +- .../Advanced/LoopSignal{T}.cs | 23 +- .../MapIndexedSignal{TSource,TResult}.cs | 2 +- .../Advanced/MergeCoordinator{T}.cs | 4 +- .../Advanced/MergeSignal{T}.cs | 2 +- .../Advanced/RecoverSignal{T,TException}.cs | 6 +- .../Advanced/RepeatSourceCoordinator{T}.cs | 2 +- .../Advanced/RepeatSourceWitness{T}.cs | 2 +- .../Advanced/ResumeSignal{T}.cs | 5 +- .../Advanced/ReturnSignal{T}.cs | 30 +-- .../Advanced/ScheduledEnumerableSignal{T}.cs | 6 +- .../Advanced/SequenceSignal.cs | 4 +- .../Advanced/SparkWitness.cs | 2 +- src/Primitives.Shared/Advanced/StartSignal.cs | 4 +- .../Advanced/StartSignal{T}.cs | 4 +- .../Advanced/SubscriptionScheduling.cs | 13 +- .../SwitchMapSignal{TSource,TResult}.cs | 4 +- .../Advanced/SwitchWitness{T}.cs | 10 +- .../Advanced/SynchronizeObjectSignal{T}.cs | 2 +- .../Advanced/TaskAnyWitness{T}.cs | 4 +- .../Advanced/TaskChainCoordinatorState.cs | 6 +- .../Advanced/TaskChainSignal{T}.cs | 2 +- .../Advanced/TaskCountWitness{T}.cs | 4 +- .../Advanced/ThrowSignal{T}.cs | 32 +-- .../Advanced/TimeIntervalWitness.cs | 2 +- src/Primitives.Shared/Advanced/TimerSlot.cs | 8 +- .../Advanced/UnsparkWitness.cs | 2 +- .../Advanced/WitnessLifetime.cs | 6 +- .../Advanced/WitnessOnSignal{T}.cs | 73 +++-- .../Concurrency/SequencerWorkItem.cs | 2 +- .../Concurrency/SequencerWorkItemDisposal.cs | 6 +- .../ConnectableSignalRxNameExtensions.cs | 8 +- src/Primitives.Shared/Core/Spark.cs | 6 +- .../Core/SparkCompositeFormats.cs | 5 +- src/Primitives.Shared/Core/Spark{T}.cs | 20 +- src/Primitives.Shared/Handle.cs | 3 +- src/Primitives.Shared/LinqExtensions.cs | 42 ++- .../SignalOperatorMixins.BlendUnique.cs | 13 +- .../SignalOperatorMixins.BufferSignals.cs | 6 +- ...SignalOperatorMixins.ChooseSwitchSelect.cs | 5 +- .../SignalOperatorMixins.CollectionSignals.cs | 16 +- ...lOperatorMixins.CombineLatest.WideArity.cs | 14 +- .../SignalOperatorMixins.CombineLatest.cs | 26 +- .../SignalOperatorMixins.Coordinators.cs | 21 +- .../SignalOperatorMixins.SchedulerSignals.cs | 10 +- .../SignalOperatorMixins.StatefulSignals.cs | 38 +-- .../SignalOperatorMixins.Switch.cs | 2 +- ...alOperatorMixins.SyncLatest.MultiSource.cs | 4 +- ...gnalOperatorMixins.SyncLatest.WideArity.cs | 14 +- src/Primitives.Shared/SignalOperatorMixins.cs | 13 +- ...alOperatorParityMixins.AggregateHelpers.cs | 14 +- ...OperatorParityMixins.AwaitableTerminals.cs | 8 +- ...atorParityMixins.BooleanTerminalHelpers.cs | 4 +- .../SignalOperatorParityMixins.FlatMap.cs | 13 +- .../SignalOperatorParityMixins.ForkJoin.cs | 2 +- .../SignalOperatorParityMixins.Helpers.cs | 22 +- .../SignalOperatorParityMixins.Probe.cs | 14 +- ...ixins.RxNames.CombineLatest.Collections.cs | 7 +- ...rityMixins.RxNames.CombineLatest.Tuples.cs | 2 +- ...yMixins.RxNames.CombineLatest.WideArity.cs | 20 +- ...ratorParityMixins.RxNames.CombineLatest.cs | 4 +- ...ratorParityMixins.RxNames.SubscribeSafe.cs | 20 +- .../SignalOperatorParityMixins.RxNames.cs | 18 +- .../SignalOperatorParityMixins.cs | 18 +- .../Signals/PrioritySemaphoreSignal{T}.cs | 15 +- .../Signals/ReplaySignal{T}.cs | 129 ++++----- .../Signals/ScheduledSignal{T}.cs | 20 +- .../Signals/SignalExtensions{Create}.cs | 6 +- .../Signals/SignalExtensions{FromTask}.cs | 44 ++- .../Signals/SignalExtensions{GetAwaiter}.cs | 10 +- .../Signals/SignalExtensions{Recover}.cs | 6 +- .../Signals/Signal{Collect}.cs | 6 +- .../Signals/Signal{Create}.cs | 98 +++---- src/Primitives.Shared/Signals/Signal{Emit}.cs | 40 +-- .../Signals/Signal{Factories}.cs | 145 +++++----- src/Primitives.Shared/Signals/Signal{Fail}.cs | 56 ++-- .../Signals/Signal{FromTask}.cs | 125 ++++----- .../Signals/Signal{GetAwaiter}.cs | 38 +-- src/Primitives.Shared/Signals/Signal{None}.cs | 43 ++- .../Signals/Signal{RxAliases}.cs | 15 +- .../Signals/Signal{Silent}.cs | 17 +- .../Signals/Signal{Subjects}.cs | 3 +- src/Primitives.Shared/Signals/TaskSignal.cs | 48 ++-- .../Signals/TaskSignal{T}.cs | 56 ++-- .../Signals/TaskTerminalCompletion{T}.cs | 19 +- .../Advanced/AsyncSerialGate.cs | 24 +- .../Advanced/BackgroundJobSignal{T}.cs | 7 +- .../Advanced/CallbackWitnessAsync.cs | 3 +- .../Advanced/FlatMapCoordinator{TResult}.cs | 44 +-- .../Advanced/ForwardingWitnessAsync.cs | 5 +- .../Advanced/IReentrantAsyncDisposable.cs | 7 +- .../Advanced/IntervalSignal.cs | 6 +- .../Advanced/IntervalSubscription.cs | 3 +- .../Advanced/LeadSubscription{T}.cs | 6 +- .../Advanced/PooledDelaySource.cs | 44 +-- .../Advanced/SingleElementWitness.cs | 12 +- .../Advanced/StartSignal{TResult}.cs | 4 +- .../Advanced/StartSubscription{TResult}.cs | 4 +- ...T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,TResult}.cs | 5 +- ...2,T3,T4,T5,T6,T7,T8,T9,T10,T11,TResult}.cs | 5 +- ...,T4,T5,T6,T7,T8,T9,T10,T11,T12,TResult}.cs | 5 +- ...T5,T6,T7,T8,T9,T10,T11,T12,T13,TResult}.cs | 5 +- ...6,T7,T8,T9,T10,T11,T12,T13,T14,TResult}.cs | 5 +- ...,T8,T9,T10,T11,T12,T13,T14,T15,TResult}.cs | 5 +- ...T9,T10,T11,T12,T13,T14,T15,T16,TResult}.cs | 5 +- .../SyncLatest2Coordinator{T1,T2,TResult}.cs | 5 +- ...yncLatest3Coordinator{T1,T2,T3,TResult}.cs | 5 +- ...Latest4Coordinator{T1,T2,T3,T4,TResult}.cs | 5 +- ...est5Coordinator{T1,T2,T3,T4,T5,TResult}.cs | 5 +- ...6Coordinator{T1,T2,T3,T4,T5,T6,TResult}.cs | 5 +- ...ordinator{T1,T2,T3,T4,T5,T6,T7,TResult}.cs | 5 +- ...inator{T1,T2,T3,T4,T5,T6,T7,T8,TResult}.cs | 5 +- ...tor{T1,T2,T3,T4,T5,T6,T7,T8,T9,TResult}.cs | 5 +- .../Advanced/SyncLatestCoordinatorBase.cs | 28 +- ...tEnumerableCoordinator{TSource,TResult}.cs | 10 +- .../Advanced/SyncLatestIndexedWitness.cs | 14 +- .../Advanced/SyncLatestLifecycle.cs | 18 +- .../Advanced/TakeUntilLifecycle.cs | 23 +- .../Advanced/TakeUntilSourceWitness.cs | 6 +- .../Advanced/TaskResultCompletionSource.cs | 2 +- .../Advanced/TaskResultWitnessAsyncBase.cs | 4 +- .../Advanced/TaskSignalSubscription{T}.cs | 40 ++- .../ConcurrencyRaceHelpers.cs | 26 +- .../ConcurrentWitnessCallsException.cs | 8 +- .../ConnectableSignalAsync.cs | 27 +- .../ConnectableSignalAsyncHelper.cs | 5 +- .../ConnectableSignalAsyncState{T}.cs | 13 +- .../Disposables/DisposableAsync.cs | 23 +- .../Disposables/DisposableAsyncSlot.cs | 16 +- .../Disposables/MultipleDisposableAsync.cs | 55 ++-- .../SingleAssignmentDisposableAsync.cs | 55 ++-- .../SingleReplaceableDisposableAsync.cs | 29 +- .../Helpers/DisposalHelper.cs | 4 +- .../Helpers/FireAndForgetHelper.cs | 9 +- .../Helpers/SubscriptionHelper.cs | 5 +- .../IObservableAsync.cs | 14 +- .../IObserverAsync.cs | 25 +- .../Mixins/DisposableAsyncExtensions.cs | 17 +- .../Mixins/SignalExtensions.cs | 27 +- .../Observables/Create.cs | 20 +- .../Observables/Defer.cs | 33 +-- .../Observables/Empty.cs | 17 +- .../Observables/FromAsync.cs | 13 +- .../Observables/Interval.cs | 18 +- .../Observables/Never.cs | 9 +- .../Observables/Range.cs | 11 +- .../Observables/Return.cs | 17 +- .../Observables/Throw.cs | 15 +- .../Observables/Timer.cs | 4 +- .../Observables/ToAsyncSignal.cs | 32 +-- .../Operators/AnyAllAsync.cs | 9 +- .../Operators/Blend.cs | 76 ++---- .../Operators/Cast.cs | 13 +- .../Operators/Chain.cs | 17 +- .../Operators/ChainEnumerableSignal{T}.cs | 21 +- .../Operators/ChainSignalSourcesSignal{T}.cs | 13 +- .../Operators/CompletionSignalDelegate.cs | 4 +- .../Operators/ContainsAsync.cs | 3 - .../Operators/CountAsync.cs | 3 - .../Operators/Distinct.cs | 24 +- .../Operators/Expire.cs | 70 +++-- .../Operators/FirstAsync.cs | 11 +- .../Operators/FirstOrDefaultAsync.cs | 4 - .../Operators/FlatMap.cs | 7 +- .../Operators/Fold.cs | 18 +- .../Operators/ForEachAsync.cs | 19 +- .../Operators/GroupBy.cs | 19 +- .../Operators/GroupedAsyncSignal.cs | 4 +- .../Operators/Keep.cs | 24 +- .../Operators/KeepType.cs | 10 +- .../Operators/LastAsync.cs | 13 +- .../Operators/LastOrDefaultAsync.cs | 3 - .../Operators/Lead.cs | 8 +- .../Operators/LongCountAsync.cs | 3 - .../Operators/Map.cs | 25 +- .../Operators/Multicast.cs | 61 ++--- .../Operators/OnDispose.cs | 18 +- .../Operators/OnErrorResumeAsFailure.cs | 8 +- .../Operators/Pair.cs | 21 +- .../Operators/ParityHelpers.FilterFusions.cs | 42 +-- .../ParityHelpers.OperatorFusions.cs | 85 ++---- .../Operators/ParityHelpers.Partition.cs | 34 +-- .../Operators/ParityHelpers.Start.cs | 5 +- .../Operators/ParityHelpers.cs | 11 +- .../Operators/Reattempt.cs | 2 - .../Operators/Recover.cs | 26 +- .../Operators/ReduceAsync.cs | 15 +- .../Operators/RefCount.cs | 13 +- .../Operators/Shift.cs | 9 +- .../SignalAsyncExtensions.Catch.Rx.cs | 5 +- ...AsyncExtensions.DistinctUntilChanged.Rx.cs | 8 +- .../SignalAsyncExtensions.Timeout.Rx.cs | 20 +- .../Operators/SingleAsync.cs | 22 +- .../Operators/SingleOrDefaultAsync.cs | 21 +- .../Operators/Skip.cs | 7 +- .../Operators/SkipWhile.cs | 15 +- .../Operators/StartWith.cs | 12 +- .../Operators/SubscribeAsync.cs | 21 +- .../Operators/SwitchTo.cs | 3 - .../Operators/SwitchToSignal.cs | 8 +- .../Operators/SyncLatest10.cs | 6 +- .../Operators/SyncLatest11.cs | 6 +- .../Operators/SyncLatest12.cs | 6 +- .../Operators/SyncLatest13.cs | 6 +- .../Operators/SyncLatest14.cs | 6 +- .../Operators/SyncLatest15.cs | 6 +- .../Operators/SyncLatest16.cs | 6 +- .../Operators/SyncLatest8.cs | 6 +- .../Operators/SyncLatest9.cs | 6 +- .../Operators/SyncLatestEnumerable.cs | 16 +- .../Operators/Take.cs | 12 +- .../Operators/TakeUntil.PredicateSignals.cs | 6 +- .../Operators/TakeUntil.cs | 32 +-- .../Operators/TakeUntilOptions.cs | 6 +- .../Operators/TakeWhile.cs | 11 +- .../Operators/Tap.cs | 3 - .../UnhandledExceptionHandler.cs | 26 +- .../WitnessAsync.cs | 208 ++++++--------- .../Advanced/AggregateWitness.cs | 7 +- .../Advanced/AllPredicateWitness.cs | 2 +- .../Advanced/AnyPredicateWitness.cs | 2 +- .../Advanced/AnyWitness.cs | 2 +- .../Advanced/AppendDelegateWitness.cs | 2 +- .../Advanced/AppendWitness.cs | 4 +- .../Advanced/AsyncEnumerableSignal{T}.cs | 24 +- .../Advanced/BufferWitness.cs | 14 +- .../Advanced/CallbackWitness.cs | 1 - .../Advanced/CollectArrayWitness.cs | 2 +- .../Advanced/CollectListWitness.cs | 2 +- .../Advanced/ContainsWitness.cs | 2 +- .../Advanced/DefaultIfEmptyWitness.cs | 4 +- .../Advanced/DisposedMarker.cs | 2 +- .../Advanced/DisposedWitness{T}.cs | 2 +- .../Advanced/DistinctByCountAggregator.cs | 4 +- .../Advanced/DistinctByLongCountAggregator.cs | 4 +- .../Advanced/DistinctByWitness.cs | 4 +- .../Advanced/DistinctWitness.cs | 6 +- .../Advanced/EmptyWitness{T}.cs | 10 +- .../Advanced/FoldWitness.cs | 2 +- .../Advanced/FromEnumerableSignal{T}.cs | 28 +- .../Advanced/IAggregator.cs | 6 +- .../Advanced/IInlineSignal{T}.cs | 14 +- .../Advanced/IRequireCurrentThread.cs | 10 +- .../Advanced/IgnoreValuesWitness.cs | 2 +- .../Advanced/ImmediateReturnSignal{T}.cs | 27 +- .../Advanced/ImmediateThrowSignal{T}.cs | 25 +- .../Advanced/ImmutableEmptySignal{T}.cs | 28 +- .../Advanced/ImmutableNeverSignal{T}.cs | 17 +- .../Advanced/ImmutableReturnFalseSignal.cs | 25 +- .../Advanced/ImmutableReturnInt32Signal.cs | 39 ++- .../Advanced/ImmutableReturnTrueSignal.cs | 25 +- .../Advanced/KeepNotNullWitness.cs | 2 +- .../Advanced/KeepTypeWitness.cs | 2 +- .../Advanced/RangeConcatSignal.cs | 2 +- .../Advanced/RangeSignal.cs | 28 +- .../Advanced/RangeZipSignal{TResult}.cs | 32 +-- .../Advanced/ReduceWitness.cs | 2 +- .../Advanced/RepeatSignal{T}.cs | 34 +-- .../Advanced/SinkSubscription.cs | 9 +- .../Advanced/SinkTerminal.cs | 13 +- .../Advanced/SkipWhileWitness.cs | 4 +- .../Advanced/SkipWitness.cs | 2 +- .../Advanced/StatefulWitness.cs | 1 - .../Advanced/SubscriptionSlots.cs | 2 +- .../Advanced/SynchronizeWitness.cs | 9 +- .../Advanced/TakeWhileWitness.cs | 2 +- .../Advanced/TakeWitness.cs | 2 +- .../Advanced/TapWitness.cs | 2 +- .../Advanced/UnfoldSignal{TState,TResult}.cs | 22 +- .../Advanced/UniqueByWitness.cs | 6 +- .../Advanced/UniqueWitness.cs | 6 +- .../Advanced/UseSignal{TResource,T}.cs | 21 +- .../Advanced/Witness.cs | 4 +- .../Concurrency/ScheduledItem.cs | 54 +--- .../Concurrency/SequencerQueue.cs | 8 +- .../ConnectableSignal{T}.cs | 6 +- .../ExceptionExtensions.cs | 2 +- src/ReactiveUI.Primitives.Core/Optional.cs | 28 +- src/ReactiveUI.Primitives.Core/Result.cs | 29 +- .../ActivationExtensionsEmitter.cs | 4 +- .../CodeGeneration/ActivationSource.cs | 18 +- .../CodeGeneration/GeneratedNames.cs | 12 +- .../CodeGeneration/InstanceWrapperEmitter.cs | 4 - .../CodeGeneration/PooledStringBuilder.cs | 17 +- .../CodeGeneration/SourceFileWriter.cs | 5 +- .../CodeGeneration/StaticEventsEmitter.cs | 3 +- .../Constants.cs | 7 +- .../EventGenerator.cs | 31 +-- .../GeneratorStepNames.cs | 6 +- .../Concurrency/WasmScheduler.cs | 53 ++-- .../Advanced/DispatchSequencerState.cs | 25 +- .../Concurrency/CurrentThreadSequencer.cs | 14 +- .../Concurrency/ImmediateSequencer.cs | 2 +- .../Concurrency/Sequencer.Simple.cs | 11 +- .../Concurrency/SequencerExtensions.cs | 63 +---- .../SynchronizationContextSequencer.cs | 3 +- .../Concurrency/SynchronizedSequencerQueue.cs | 5 +- .../Concurrency/TaskPoolSequencer.cs | 5 +- .../Concurrency/ThreadPoolSequencer.cs | 38 ++- .../AsyncPrimitiveContractTests.cs | 21 +- .../AsyncRenameCoverageTests.cs | 4 +- .../CombiningOperatorTests.Blend.cs | 59 ++-- .../ConcurrentSignalBaseTests.cs | 20 +- .../CustomTaskScheduler.cs | 38 +++ .../DisposableTests.cs | 15 +- .../ErrorHandlingOperatorTests.cs | 10 +- .../FactorySignalTests.cs | 47 ++-- .../NewThreadTaskScheduler.cs | 33 --- .../ObserverAsyncDisposeTests.cs | 45 ++-- .../ParityHelpersOperatorFusionsTests.cs | 23 +- ...keUntilOperatorTests.CompletionDelegate.cs | 85 +++--- .../TimeBasedOperatorTests.cs | 22 +- .../AvaloniaSchedulerTests.cs | 54 ++-- .../Operators/DropIfBusyObservableTests.cs | 31 +-- .../OperatorAfterTerminalGuardTests.cs | 36 +-- .../Operators/ScanWithInitialTests.cs | 64 ++--- .../SelectAsyncConcurrentObservableTests.cs | 33 ++- .../SelectAsyncSequentialObservableTests.cs | 33 ++- .../SelectLatestAsyncObservableTests.cs | 51 ++-- .../SubscribeAsyncObservableTests.cs | 51 ++-- .../SynchronizeAsyncObservableTests.cs | 5 - .../ThrottleUntilTrueObservableTests.cs | 92 ++++--- .../Operators/WhileObservableTests.cs | 83 +++--- .../ReactiveExtensionsPortedTests.cs | 3 +- .../ReactiveExtensionsTests.Misc.cs | 42 ++- .../ReactiveExtensionsTests.Scheduling.cs | 16 +- .../ReactiveExtensionsTests.SelectAsync.cs | 15 +- .../ReactiveExtensionsTests.Synchronize.cs | 137 ++++------ .../ReactiveExtensionsTests.Throttle.cs | 57 ++-- .../MauiDispatcherSequencerTests.cs | 11 +- .../WasmSchedulerTests.cs | 251 +++++++----------- .../Common/AwaitableWitness.cs | 93 +++++++ .../Common/CancellationTokenExtensions.cs | 27 ++ .../RxNamesTests.cs | 77 +++--- .../SignalAliasCoverageTests.cs | 8 +- .../SignalFactoriesTests.Behavior.cs | 22 +- .../SignalFactoriesTests.Composition.cs | 6 +- .../SignalFactoriesTests.DefaultSequencer.cs | 36 ++- .../SignalFactoriesTests.Scheduling.cs | 6 +- .../SignalFactoriesTests.cs | 66 +++-- .../SignalOperatorMixinsTests.cs | 21 +- 399 files changed, 3238 insertions(+), 4390 deletions(-) create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/NewThreadTaskScheduler.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.cs diff --git a/src/Polyfills/ArgumentExceptionHelper.cs b/src/Polyfills/ArgumentExceptionHelper.cs index d7df3bde..d154c660 100644 --- a/src/Polyfills/ArgumentExceptionHelper.cs +++ b/src/Polyfills/ArgumentExceptionHelper.cs @@ -8,9 +8,8 @@ namespace ReactiveUI.Primitives.Internal; /// -/// Polyfill for ArgumentNullException.ThrowIfNull on target frameworks (net462-net481) that predate it. -/// On net8.0 and later this type is not compiled; consuming projects alias the ArgumentExceptionHelper -/// identifier directly to so the call sites bind to the BCL method. +/// Polyfill for ArgumentNullException.ThrowIfNull on net462-net481; net8.0 and later compile no copy of this +/// type and alias the ArgumentExceptionHelper identifier to instead. /// [ExcludeFromCodeCoverage] internal static class ArgumentExceptionHelper diff --git a/src/Polyfills/ArgumentOutOfRangeExceptionHelper.cs b/src/Polyfills/ArgumentOutOfRangeExceptionHelper.cs index 65a5d9c1..825fc1e9 100644 --- a/src/Polyfills/ArgumentOutOfRangeExceptionHelper.cs +++ b/src/Polyfills/ArgumentOutOfRangeExceptionHelper.cs @@ -70,9 +70,7 @@ internal static void ThrowIfLessThan( "Design", "SST2318:Members should not have identical bodies", Justification = - "A type-specialized polyfill overload. The bounds-check body is identical to the int overload only " - + "because the guard shape is the same; the two operate on different value types (int vs TimeSpan) and " - + "cannot forward to one another. This mirrors the BCL's per-type ThrowIfLessThan overloads.")] + "The int and TimeSpan overloads guard different value types and cannot forward to one another.")] internal static void ThrowIfLessThan( TimeSpan value, TimeSpan other, diff --git a/src/Polyfills/ObjectDisposedExceptionHelper.cs b/src/Polyfills/ObjectDisposedExceptionHelper.cs index 8867323b..c74b6361 100644 --- a/src/Polyfills/ObjectDisposedExceptionHelper.cs +++ b/src/Polyfills/ObjectDisposedExceptionHelper.cs @@ -7,9 +7,8 @@ namespace ReactiveUI.Primitives.Internal; /// -/// Polyfill for ObjectDisposedException.ThrowIf on target frameworks (net462-net481) that predate it. -/// On net8.0 and later this type is not compiled; consuming projects alias the ObjectDisposedExceptionHelper -/// identifier directly to so the call sites bind to the BCL method. +/// Polyfill for ObjectDisposedException.ThrowIf on net462-net481; net8.0 and later compile no copy of this +/// type and alias the ObjectDisposedExceptionHelper identifier to instead. /// [ExcludeFromCodeCoverage] internal static class ObjectDisposedExceptionHelper diff --git a/src/Polyfills/TaskCompletionSource.cs b/src/Polyfills/TaskCompletionSource.cs index b5b10cc2..615dcae8 100644 --- a/src/Polyfills/TaskCompletionSource.cs +++ b/src/Polyfills/TaskCompletionSource.cs @@ -19,7 +19,7 @@ internal sealed class TaskCompletionSource /// Initializes a new instance of the class. [SuppressMessage("Concurrency", "PSH1302", Justification = - "BCL-parity polyfill; must match the framework ctor's TaskCreationOptions.None default, not force async continuations.")] + "Matches the framework constructor's TaskCreationOptions.None default.")] public TaskCompletionSource() => _inner = new(); /// Transitions the underlying task to the state. @@ -47,9 +47,7 @@ internal sealed class TaskCompletionSource "Design", "SST2318:Members should not have identical bodies", Justification = - "Distinct BCL-parity surface members with different contracts and return types: SetCanceled is void and " - + "TrySetCanceled returns bool. They share the same backing call here but must track the framework surface " - + "independently, so they are deliberately kept as separate members rather than one forwarding to the other.")] + "SetCanceled returns void and TrySetCanceled returns bool; each tracks its own framework contract.")] internal bool TrySetCanceled() => _inner.TrySetCanceled(); /// Attempts to transition the underlying task to the state for the specified token. diff --git a/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs b/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs index 235386f3..11581b65 100644 --- a/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs +++ b/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs @@ -8,23 +8,16 @@ namespace ReactiveUI.Primitives.Extensions; /// -/// Provides helper methods for throwing when -/// constructor-supplied state on an operator is missing at the time it is consumed. -/// The thrown message is composed of the captured member name and the caller member -/// (typically Subscribe or the enclosing type), so call sites just pass the -/// field being validated. +/// Throws when constructor-supplied operator state is missing at the +/// point it is consumed, naming the captured member and the calling member in the message. /// [ExcludeFromCodeCoverage] internal static class InvalidOperationExceptionHelper { - /// - /// Throws an if is null. - /// The exception message is composed from the captured argument expression and the - /// caller member, e.g. "'source' was not supplied to 'Subscribe'.". - /// + /// Throws when is null. /// The reference type field to validate as non-null. - /// The validated member's name, captured from the expression via . - /// The void-throwing caller's name, captured via . + /// The validated member's name, captured from the expression. + /// The calling member's name. /// is . internal static void ThrowIfNull( [NotNull] object? argument, @@ -42,13 +35,13 @@ internal static void ThrowIfNull( } /// - /// Validates an argument and returns it if it is not null, otherwise throws an . - /// Designed for use in primary constructor initializers. + /// Returns when non-null and otherwise throws, shaped for use in a primary + /// constructor initializer. /// /// The type of the argument. /// The argument to validate. - /// The validated reference-type argument's name, captured from the expression via . - /// The reference-type-checking caller's name, captured via . + /// The validated argument's name, captured from the expression. + /// The calling member's name. /// The non-null argument. /// is . internal static T Check( @@ -68,12 +61,12 @@ internal static T Check( } /// - /// Validates a string argument and returns it if it is not null or empty, otherwise throws an . - /// Designed for use in primary constructor initializers. + /// Returns when it is neither null nor empty and otherwise throws, shaped for use + /// in a primary constructor initializer. /// /// The argument to validate. - /// The validated string argument's name, captured from the expression via . - /// The string-checking caller's name, captured via . + /// The validated argument's name, captured from the expression. + /// The calling member's name. /// The non-null, non-empty argument. /// is or empty. internal static string Check( diff --git a/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs b/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs index 89c6ef95..7271810a 100644 --- a/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs +++ b/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs @@ -12,14 +12,10 @@ namespace ReactiveUI.Primitives.Extensions.Internal; #endif /// -/// Shared queue-and-single-drain marshaller used by the synchronous scheduler-marshalling operator -/// sinks (ObserveOn, Conflate). Each of those sinks previously hand-rolled the same gate, -/// FIFO queue, drain-in-flight flag, terminal flag, enqueue-and-schedule logic, and dequeue loop on top -/// of identical fields; this helper centralises that machinery so the per-sink class only carries the -/// operator-specific notification handling. Notifications are enqueued and a single drain pass is -/// scheduled per burst (rather than one scheduled action per item), and the drain callback carries no -/// captures — the sink is passed through as an . Sinks compose one instance -/// and forward to it; there is no base class and no per-item virtual dispatch. +/// Queue-and-single-drain marshaller composed by the synchronous scheduler-marshalling operator sinks +/// (ObserveOn, Conflate): notifications are enqueued under the sink's gate and one drain pass is +/// scheduled per burst rather than one scheduled action per item. The drain callback is capture-free, taking +/// the sink through as an . /// /// The element type carried by notifications. /// The scheduler each drain pass runs on. @@ -42,8 +38,7 @@ internal sealed class ScheduledDrainState(ISequencer scheduler, IDrainTarget /// Set to once a terminal notification has been delivered or the sink disposed. private bool _done; - /// Gets a value indicating whether the sink has reached a terminal state. Read inside - /// the sink's gate by callers that need to short-circuit once terminated. + /// Gets a value indicating whether the sink has reached a terminal state; read it inside the sink's gate. internal bool Done => _done; /// Enqueues an OnNext notification and schedules a drain pass if one isn't already running. @@ -60,7 +55,7 @@ internal sealed class ScheduledDrainState(ISequencer scheduler, IDrainTarget [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void EnqueueCompleted() => Enqueue(new(DrainNotificationKind.Completed, default!, null)); - /// Records the upstream subscription, or disposes it immediately if the sink is already done. + /// Records the upstream subscription, or disposes it immediately when the sink has terminated. /// The upstream subscription handle. internal void Attach(IDisposable subscription) { @@ -109,9 +104,8 @@ internal void Terminate() /// the still-queued notifications are abandoned because checks the done flag first. internal void MarkDoneLocked() => _done = true; - /// Begins disposal under the gate: marks the sink done, clears the queue, and detaches - /// the upstream subscription — returned to the caller so it is disposed outside the gate. Returns - /// when already disposed. + /// Begins disposal under the gate, returning the upstream subscription so the caller disposes it + /// outside the gate, or when the sink has terminated. /// The upstream subscription to dispose outside the gate, or . internal IDisposable? BeginDispose() { @@ -121,10 +115,9 @@ internal void Terminate() } } - /// Marks the sink done, clears the queue, and detaches the upstream subscription, returning it for - /// disposal outside the gate. Caller must hold the gate and have confirmed is - /// . Lets a composing sink dispose its own scheduled-work slot atomically with the - /// done transition under the same lock. + /// Marks the sink done, clears the queue, and returns the upstream subscription for disposal outside + /// the gate. Caller must hold the gate and have confirmed is , which + /// lets it retire its own scheduled-work slot atomically with the done transition. /// The upstream subscription to dispose outside the gate, or . internal IDisposable? BeginDisposeLocked() { diff --git a/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs b/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs index 6af78f2c..e355ba5c 100644 --- a/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs +++ b/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Internal; namespace ReactiveUI.Primitives.Extensions.Internal; #endif -/// Periodic scheduling helpers used by migrated extension operators. +/// Periodic scheduling helpers for . internal static class SequencerPeriodicExtensions { /// Periodic scheduling helpers for a sequencer. @@ -67,10 +67,7 @@ private static PeriodicSubscription SchedulePeriodic( return subscription; } - /// - /// Disposable state for one periodic schedule. Internal (rather than private) so coverage tests can - /// drive directly instead of via reflection. - /// + /// Disposable state for one periodic schedule, re-arming itself after each tick until disposed. /// The state type. /// The scheduler used for each tick. /// The state passed to each tick. @@ -127,7 +124,7 @@ internal void ScheduleNext(TimeSpan dueTime) }); } - /// Runs a tick and schedules the next one when still active. + /// Runs one tick and schedules the next, unless disposed. internal void Tick() { if (Volatile.Read(ref _disposed) != 0) diff --git a/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs b/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs index 1165906c..48ea0223 100644 --- a/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs +++ b/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs @@ -12,9 +12,8 @@ namespace ReactiveUI.Primitives.Extensions; #endif /// -/// Provides extension methods for subscribing to and handling reactive sequences -/// in a synchronous or blocking manner. These methods offer utility functions -/// to retrieve emitted values, handle completion, and capture errors from observables. +/// Provides extension methods that subscribe to a sequence and block the calling thread until it produces a +/// value or terminates, returning the emitted value or the captured error. /// public static class ObservableSubscriptionExtensions { @@ -240,8 +239,8 @@ private static void WaitForCompletionCore(IObservable source, ISequencer } /// - /// Subscribes to the specified observable using the provided . - /// If a scheduler is specified, the subscription is scheduled; otherwise, the subscription occurs immediately. + /// Subscribes to on , or + /// inline when no scheduler is supplied. /// /// The type of the elements in . /// The observable to subscribe to. @@ -360,9 +359,8 @@ private sealed class BlockingValueWitness(ManualResetEventSlim done) : IObser "Design", "SST2318:Members should not have identical bodies", Justification = - "This blocking witness treats completion and error identically: either terminal signal releases the " - + "gate. OnError and OnCompleted are distinct IObserver channels that share this by design, not a " - + "copy that was meant to differ.")] + "Completion and error are distinct IObserver channels that deliberately share one action: " + + "releasing the gate.")] public void OnCompleted() => done.Set(); } diff --git a/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs b/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs index a9826886..25ad98c5 100644 --- a/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs @@ -11,10 +11,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Projection operator that emits for every source -/// element. Replaces the source.Select(_ => RxVoid.Default) pattern, -/// avoiding the per-subscription closure allocation that the projection lambda -/// would otherwise capture. +/// Projection operator that emits for every source element without allocating a +/// projection closure. /// /// The element type of the source observable (ignored). /// The source observable whose values are ignored. @@ -28,7 +26,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new AsSignalWitness(observer)); } - /// Forwarding observer that replaces every value with . Error and completion signals pass through unchanged. + /// Forwards every source value as , passing error and completion through unchanged. /// The downstream observer. private sealed class AsSignalWitness(IObserver downstream) : IObserver { diff --git a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs index 524306f5..42a334d3 100644 --- a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs @@ -35,10 +35,7 @@ public IDisposable Subscribe(IObserver> observer) return new DisposableBag(subscription, sink); } - /// - /// Sink that manages the buffer and idle timer. Composes for - /// the shared gate / timer / done-flag plumbing so this class only carries the buffer logic. - /// + /// Sink that accumulates elements and emits the buffer once the idle timer elapses. /// The downstream observer. /// The idle time period. /// The scheduler. diff --git a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs index 0421684d..3960cbf3 100644 --- a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs @@ -34,11 +34,9 @@ public IDisposable Subscribe(IObserver observer) } /// - /// Single observer that combines two previously-distinct concerns into one allocation: - /// (1) marshals upstream notifications onto the scheduler thread — delegated to the shared - /// FIFO queue and scheduled drain — and (2) applies the conflate - /// time-window throttle to each notification. End-user-observable - /// semantics are unchanged from the prior two-observer implementation. + /// Single observer that both marshals upstream notifications onto the scheduler thread, through the + /// queue and scheduled drain, and applies the conflate time-window + /// throttle to each notification. /// internal sealed class ConflateSink : IObserver, IDisposable, IDrainTarget { @@ -149,10 +147,8 @@ void IDrainTarget.Drain() [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void AttachSourceSubscription(IDisposable subscription) => _state.Attach(subscription); - /// Applies the throttle-window decision to a dequeued value and either emits inline or - /// schedules a deferred emission. The emission bodies live in covered helpers; only this - /// race-guarded shell (whose already-done early-out is reachable only when a concurrent dispose - /// flips the flag between the drain dequeue and this gate acquisition) is excluded. + /// Applies the throttle-window decision to a dequeued value, emitting it inline or scheduling a + /// deferred emission at the end of the window. /// The value to forward. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void ProcessNext(T value) @@ -162,6 +158,7 @@ private void ProcessNext(T value) lock (_gate) { + // Race-only: a concurrent dispose can flip the done flag between the drain dequeue and this gate. if (_state.Done) { return; @@ -228,13 +225,12 @@ private void EmitInline(T value) /// Forwards an error to downstream and terminates the sink. /// The error to forward. - /// The already-terminated early-out is reachable only when a concurrent dispose flips the - /// flag between the drain dequeue and this gate acquisition; excluded as race-only. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void ForwardError(Exception error) { lock (_gate) { + // Race-only: a concurrent dispose can flip the done flag between the drain dequeue and this gate. if (_state.Done) { return; @@ -247,14 +243,13 @@ private void ForwardError(Exception error) _downstream.OnError(error); } - /// Forwards completion, deferring if a throttled emission is still scheduled. - /// The already-terminated early-out is reachable only when a concurrent dispose flips the - /// flag between the drain dequeue and this gate acquisition; excluded as race-only. + /// Forwards completion, deferring it when a throttled emission is scheduled. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void ForwardCompleted() { lock (_gate) { + // Race-only: a concurrent dispose can flip the done flag between the drain dequeue and this gate. if (_state.Done) { return; diff --git a/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs b/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs index cbd0270a..63703687 100644 --- a/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs @@ -32,7 +32,7 @@ public IDisposable Subscribe(IObserver observer) return new DisposableBag(subscription, sink); } - /// Sink for the debounce immediate observable. + /// Sink that forwards the first value inline and debounces every later value by the due time. /// The downstream observer. /// The debounce duration. /// The scheduler to use for timing. diff --git a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs index 266b6170..e92e72d6 100644 --- a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs @@ -35,10 +35,7 @@ public IDisposable Subscribe(IObserver observer) return new DisposableBag(subscription, sink); } - /// - /// Sink for the debounce-until observable. Composes for the - /// shared gate / timer / done-flag plumbing so this class only carries the OnNext logic. - /// + /// Sink that forwards a value inline when the condition holds and otherwise after the debounce window. /// The downstream observer. /// The debounce duration. /// The condition. diff --git a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs index 60a46e1b..bb69cb0d 100644 --- a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs @@ -34,10 +34,7 @@ public IDisposable Subscribe(IObserver> observer) return sink; } - /// - /// Sink that manages staleness detection. Composes for the - /// shared gate / timer / done-flag plumbing so this class only carries the OnNext / schedule logic. - /// + /// Sink that re-arms the staleness timer on each upstream value and emits a stale marker when the window elapses. /// The downstream observer. /// The staleness period. /// The scheduler. @@ -52,7 +49,7 @@ private sealed class DetectStaleSink( /// Shared timer / done-flag plumbing. private readonly TimerSinkState> _state = new(downstream); - /// Upstream subscription handle, set once via so the sink can tear it down on dispose without a wrapper bag. + /// Upstream subscription handle, set once via and disposed with the sink. private IDisposable? _sourceSubscription; /// Records the upstream subscription for disposal. @@ -119,9 +116,7 @@ public void Dispose() Interlocked.Exchange(ref _sourceSubscription, null)?.Dispose(); } - /// Schedules the staleness notification. Uses the state-carrying scheduler - /// overload with a static lambda so no per-reschedule closure capturing this is - /// allocated (the timer re-arms on every upstream emission). + /// Arms the staleness timer, which re-arms on every upstream emission without allocating a per-arm closure. private void ScheduleStale() => _state.Timer.Disposable = scheduler.Schedule(this, stalenessPeriod, static (_, self) => self.OnStaleTimer()); diff --git a/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs b/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs index 3c8570bd..529016f3 100644 --- a/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs @@ -11,11 +11,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Flattening operator that subscribes to an upstream sequence of -/// batches and emits each contained element -/// individually. Replaces the -/// Observable.Create<T>(o => source.ObserveOnSafe(s).Subscribe(v => o.FastForEach(v))) -/// pattern with a single operator + observer pair. +/// Flattening operator that subscribes to an upstream sequence of batches and emits +/// each contained element individually. /// /// The element type emitted to the downstream observer. /// The source observable of enumerables. diff --git a/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs b/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs index e37f7674..b8a06fde 100644 --- a/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs @@ -11,11 +11,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Factory operator that emits each element of an -/// to a single subscriber, then completes. The emission can optionally be -/// marshalled through a scheduler. Replaces the -/// Observable.Create<T>(o => scheduler.ScheduleSafe(() => o.FastForEach(source))) -/// pattern with a dedicated implementation. +/// Factory operator that emits each element of an to a single subscriber and then +/// completes, optionally marshalling the emission through a scheduler. /// /// The element type emitted to the downstream observer. /// The source enumerable whose elements are pumped on subscription. diff --git a/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs b/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs index e9027274..3c73db56 100644 --- a/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs @@ -34,7 +34,7 @@ public IDisposable Subscribe(IObserver> observer) return sink; } - /// The sink for the heartbeat operator. + /// Sink that forwards upstream values and emits a heartbeat whenever the period elapses without one. /// The downstream observer. /// The period between heartbeats. /// The scheduler to run the heartbeat timer on. @@ -49,7 +49,7 @@ private sealed class HeartbeatSink( /// The subscription to the periodic heartbeat timer. private readonly MutableDisposable _timerSubscription = new(); - /// Upstream subscription handle; set once via so the sink can tear it down in without needing a wrapper bag. + /// Upstream subscription handle, set once via and torn down in . private IDisposable? _sourceSubscription; /// Whether the sink has completed or been disposed. diff --git a/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs b/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs index 270dcb71..574dc407 100644 --- a/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs @@ -46,7 +46,7 @@ public IDisposable Subscribe(IObserver observer) return new DisposableBag(sourceSub, conditionSub, sink); } - /// Sinks the source observable and conditionally observes on different schedulers. + /// Sink that forwards each value on whichever scheduler the latest condition value selects. /// The downstream observer. /// The scheduler to use when condition is true. /// The scheduler to use when condition is false. diff --git a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs index 610a58c3..f17f3740 100644 --- a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs @@ -11,12 +11,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Marshals every source notification onto the supplied , preserving order. -/// Replaces the System.Reactive.Linq.Observable.ObserveOn delegation behind the sync -/// ObserveOnSafe / ObserveOnIf helpers with our own queue-and-single-drain marshaller: -/// notifications are enqueued and a single drain pass is scheduled per burst (rather than one -/// scheduled action per item). The shared queue / gate / drain machinery lives in -/// ; this sink only carries the forward-everything drain handling. +/// Marshals every source notification onto the supplied , preserving order: notifications +/// are enqueued in and one drain pass is scheduled per burst rather than one +/// scheduled action per item. /// /// The element type of the source sequence. /// The source observable. diff --git a/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs b/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs index 9171802f..2e67051b 100644 --- a/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs +++ b/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs @@ -9,9 +9,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Bundled retry configuration for : retry count, -/// delay schedule, scheduler, and an optional error sink. A -/// so it stays allocation-free and keeps the constructors that consume it to a short parameter list. +/// Bundled retry configuration for : retry count, delay schedule, +/// scheduler, and an optional error sink. /// /// Maximum number of retries. /// Delay before the first retry. diff --git a/src/Primitives.Extensions.Shared/Operators/RetryWithDelayObservable.cs b/src/Primitives.Extensions.Shared/Operators/RetryWithDelayObservable.cs index 9592b8fb..30f2c0ff 100644 --- a/src/Primitives.Extensions.Shared/Operators/RetryWithDelayObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/RetryWithDelayObservable.cs @@ -16,10 +16,12 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// The source observable. /// The maximum number of retries. /// A function to select the delay for each retry attempt. +/// The sequencer timing retry delays; null uses the default sequencer. internal sealed class RetryWithDelayObservable( IObservable source, int retryCount, - Func delaySelector) : IObservable + Func delaySelector, + ISequencer? sequencer = null) : IObservable { /// public IDisposable Subscribe(IObserver observer) @@ -28,7 +30,7 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(delaySelector); ArgumentExceptionHelper.ThrowIfNull(observer); - RetryWithDelaySink sink = new(observer, source, retryCount, delaySelector, Sequencer.Default); + RetryWithDelaySink sink = new(observer, source, retryCount, delaySelector, sequencer ?? Sequencer.Default); sink.Run(); return sink; } diff --git a/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs b/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs index e687c2a7..05d4b096 100644 --- a/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs @@ -17,12 +17,6 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// Errors from any observable propagate to the downstream observer. /// /// The list of one-shot observables to run in order. -/// -/// Replaces patterns like sources.Concat().LastOrDefaultAsync() with a single -/// operator that subscribes sequentially. Uses an iterative loop with a sync-completion -/// flag to avoid stack overflow when sources complete synchronously during -/// Subscribe. -/// internal sealed class RunAllObservable(IReadOnlyList> sources) : IObservable { /// @@ -43,10 +37,9 @@ public IDisposable Subscribe(IObserver observer) } /// - /// Stateful observer that walks the source list sequentially. The sink subscribes itself - /// directly to each source — its own sets a - /// per-iteration flag the surrounding loop reads to decide whether to advance. This - /// replaces the previous probe-observer-per-iteration allocation pattern. + /// Stateful observer that walks the source list sequentially: the sink subscribes itself to each source, and its + /// own sets a per-iteration flag the surrounding loop reads to + /// decide whether to advance. /// /// The downstream observer. /// The source list to walk. @@ -66,10 +59,9 @@ private sealed class Sink( /// Guards against re-entrant calls. private bool _looping; - /// Per-iteration latch (0 = pending, 1 = terminated). Set by - /// when a source terminates synchronously during Subscribe; read by the surrounding - /// loop in . Accessed via so it crosses the - /// method boundary safely without needing a separate probe-observer allocation per iteration. + /// Per-iteration latch (0 = pending, 1 = terminated), set by when a source + /// terminates synchronously during Subscribe and read by the surrounding loop in + /// . private int _iterationTerminated; /// @@ -146,12 +138,10 @@ internal void RunNext() } /// Emits the terminal and completes once all sources have run. - /// The already-done early-out is only reachable when a concurrent dispose latches between the - /// loop exit and this call; this small completion shell is excluded from coverage as race-only while the - /// trampoline loop in stays covered. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void CompleteRun() { + // Race-only: a concurrent dispose can latch the done flag between the loop exit and this call. if (Interlocked.Exchange(ref _done, 1) != 0) { return; diff --git a/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs b/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs index 28b7181a..fa93ee26 100644 --- a/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs +++ b/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs @@ -10,9 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// /// Bundled scheduling configuration shared by and -/// . Carrying these parameters as a single readonly record -/// struct keeps observable/observer constructor parameter counts low, avoids SA1117-style parameter -/// soup, and lets the compiler copy the configuration into capture-free scheduler lambdas. +/// , copied by value into capture-free scheduler lambdas. /// /// The element type emitted by the configured observable. /// The scheduler on which each emission is dispatched. diff --git a/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs b/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs index 7de6946b..8d8832d7 100644 --- a/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs @@ -11,21 +11,14 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Source-driven scheduled observable. Subscribes to an upstream -/// and, for every emitted value, schedules a -/// callback on the supplied that applies an optional -/// side-effect and/or transform -/// before forwarding the value to the downstream observer. Replaces the -/// Observable.Create<T>(o => source.Subscribe(v => scheduler.Schedule(...))) -/// family of source-driven Schedule overloads. +/// Source-driven scheduled observable: for every upstream value, schedules a callback on the supplied +/// that applies an optional side-effect and/or +/// transform before forwarding the value downstream. /// /// The element type of the source observable. /// -/// To match the original source.Subscribe(Action<T>) semantics, this -/// operator only forwards . Source errors and -/// completion are intentionally not propagated to the downstream observer; that -/// preserves the historical behaviour of Observable.Create + a -/// next-only subscription. +/// Only is forwarded: source errors and completion never reach the downstream +/// observer, so a subscriber sees no terminal notification. /// internal sealed class ScheduledSourceObservable : IObservable { @@ -56,11 +49,8 @@ public IDisposable Subscribe(IObserver observer) } /// - /// Carries the per-emission state into the scheduled callback so the - /// scheduler lambda does not capture any fields. A - /// so it rides inside the - /// scheduler's work item by value rather than as a separate per-emission heap - /// allocation. + /// Carries the per-emission state by value into the scheduled callback so the scheduler lambda captures + /// nothing. /// /// The downstream observer. /// The value to emit. @@ -129,15 +119,13 @@ public void OnNext(T value) /// public void OnError(Exception error) { - // Intentionally not forwarded: original Observable.Create + Subscribe(Action) - // pattern silently dropped source errors. Preserving that behaviour. + // Not forwarded: this operator is next-only by contract. } /// public void OnCompleted() { - // Intentionally not forwarded: original Observable.Create + Subscribe(Action) - // pattern silently dropped completion. Preserving that behaviour. + // Not forwarded: this operator is next-only by contract. } } } diff --git a/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs b/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs index 4d9dc857..d5a61fa6 100644 --- a/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs @@ -11,16 +11,15 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Single-value scheduled observable. On subscription, schedules a callback on -/// the supplied that applies an optional -/// side-effect and/or an optional -/// transform to the captured value, calls -/// once, then . Replaces the -/// Observable.Create<T>(o => scheduler.Schedule[Safe](due, () => o.OnNext(...))) -/// family of single-value Schedule overloads with one dedicated type -/// that captures only the fields each overload actually uses. +/// Single-value scheduled observable: on subscription, schedules a callback on the supplied +/// that applies an optional side-effect and/or an optional +/// transform to the captured value and calls once. /// /// The value type emitted to the downstream observer. +/// +/// The sequence never completes on its own: no terminal notification is sent, so the subscriber ends it by +/// disposing the subscription. +/// internal sealed class ScheduledValueObservable : IObservable { /// The value to emit. @@ -121,13 +120,9 @@ private sealed class EmitState( Func? transform, Action? action) { - /// Applies the optional side-effect and transform, then emits the value followed by completion to the captured observer. + /// Applies the optional side-effect and transform, then emits the value to the captured observer. public void Emit() { - // Preserves the original Observable.Create-based semantics: the - // scheduled callback only emits OnNext. The sequence completes - // when downstream subscribers dispose; we do not auto-call - // OnCompleted here. try { action?.Invoke(value); diff --git a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs index e683b50a..fe782db6 100644 --- a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs @@ -12,12 +12,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Optimized operator that shares a single timer per (TimeSpan, ISequencer) key. -/// Replaces the manual ConcurrentDictionary<..., Lazy<SharedTimer>> shape with a stateful -/// ConcurrentDictionary.GetOrAdd overload that doesn't allocate a -/// or its factory delegate on the hot path. -/// +/// Caches and shares one running timer per (TimeSpan, ISequencer) key. internal static class SyncTimerObservable { /// The timer cache, keyed by (TimeSpan, ISequencer). @@ -40,9 +35,8 @@ internal static IObservable Get(TimeSpan timeSpan, ISequencer schedule } /// - /// A manual implementation of a connectable timer that minimizes allocations and unrolls Rx chains. - /// Tick uses a swap-on-write array so the read path is allocation-free - /// and lock-free; subscribe / unsubscribe takes the gate and publishes a fresh array. + /// Connectable timer that fans each tick out to its observers: the tick path reads a swap-on-write observer + /// array lock-free, while subscribe and unsubscribe take the gate and publish a fresh array. /// /// The period. /// The scheduler. @@ -55,9 +49,8 @@ private sealed class SharedTimer(TimeSpan timeSpan, ISequencer scheduler) : IObs private readonly Lock _gate = new(); /// - /// Snapshot of currently active observers. Replaced (not mutated) on subscribe / unsubscribe under - /// . The tick path reads this via Volatile.Read with no lock and no - /// allocation. + /// Snapshot of active observers, replaced rather than mutated on subscribe and unsubscribe under + /// so the tick path can read it without the lock. /// private IObserver[] _observers = _emptyObservers; @@ -87,9 +80,7 @@ public IDisposable Subscribe(IObserver observer) return new TimerSubscription(this, observer); } - /// 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. + /// Ticks every currently-subscribed observer with the scheduler's current time. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Tick() => ObserverArrayHelpers.Broadcast(Volatile.Read(ref _observers), scheduler.Now.DateTime); @@ -100,29 +91,21 @@ private void Remove(IObserver observer) { lock (_gate) { - // TimerSubscription.Dispose's Interlocked guard ensures Remove is called at most - // once per subscription, and each subscription's observer was placed in _observers - // under this same lock before the disposable was returned — so RemoveOrNull always - // locates the observer by construction. + // Never null: Dispose's Interlocked guard admits one Remove per subscription, and the + // observer was added under this same lock before the disposable was handed out. var updated = ObserverArrayHelpers.RemoveOrNull(_observers, observer, _emptyObservers)!; Volatile.Write(ref _observers, updated); if (ReferenceEquals(updated, _emptyObservers)) { - // Subscribe sets _timerSubscription on first add, before the disposable is - // returned; if we reach the "all observers gone" branch, at least one Subscribe - // ran, so _timerSubscription is non-null by construction. + // Never null: reaching an empty set means Subscribe ran, which arms the timer. _timerSubscription!.Dispose(); _timerSubscription = null; } } } - /// - /// Per-subscribe disposable. Holding (parent, observer) as fields instead of capturing them in - /// a lambda removes the per-subscribe closure allocation that would - /// have required. - /// + /// Per-subscribe disposable that detaches its observer from the owning timer exactly once. /// The owning timer. /// The observer to remove on dispose. private sealed class TimerSubscription(SharedTimer parent, IObserver observer) : IDisposable diff --git a/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs b/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs index 673cfe60..6a4fcecf 100644 --- a/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs @@ -27,18 +27,13 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(scheduler); ArgumentExceptionHelper.ThrowIfNull(observer); - // Implementation of .DistinctUntilChanged().Throttle(throttle, scheduler).DistinctUntilChanged() - // But fused into a single sink to avoid multiple operator allocations and observer chains. + // Equivalent to DistinctUntilChanged().Throttle(throttle, scheduler).DistinctUntilChanged(), fused into one sink. ThrottleDistinctSink sink = new(observer, throttle, scheduler); var subscription = source.Subscribe(sink); return new DisposableBag(subscription, sink); } - /// - /// Sink that implements the throttle distinct logic. Composes - /// for the shared gate / timer / done-flag plumbing so this class only carries the throttle - /// and distinct-value tracking. - /// + /// Sink that emits the latest value once the throttle window elapses, skipping it when it equals the last emitted value. /// The observer to forward elements to. /// The throttle duration. /// The scheduler to use for timing. @@ -115,17 +110,14 @@ public void Dispose() } } - /// Emits the last received value if it differs from the last emitted value. - /// Marked [ExcludeFromCodeCoverage] because the in-lock - /// race-loser branch (sink done or no buffered value) is only reachable when the - /// scheduled callback fires concurrently with Dispose / OnCompleted, which the - /// single-threaded test harness cannot trigger. + /// Emits the last received value when it differs from the last emitted value. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void Emit() { T? toEmit; lock (_gate) { + // Race-only: reachable when this scheduled callback overlaps Dispose or a terminal notification. if (_state.Done || !_hasLastReceived) { return; diff --git a/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs b/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs index 317ac0fc..c54836b6 100644 --- a/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs @@ -145,12 +145,8 @@ public void Dispose() } /// - /// Emits the buffered value if it is still current (i.e. no newer - /// arrived after this emission was scheduled). - /// Marked [ExcludeFromCodeCoverage] because the in-lock - /// race-loser branch (sink done, emission superseded, value already drained) is only - /// reachable when the scheduled callback fires concurrently with Dispose / OnCompleted, - /// which the single-threaded test harness cannot trigger. + /// Emits the buffered value when it is still current, meaning no newer arrived after + /// this emission was scheduled. /// /// The emission id this callback was scheduled for. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] @@ -159,6 +155,7 @@ private void Emit(long id) T value; lock (_gate) { + // Race-only: reachable when this scheduled callback overlaps Dispose or a terminal notification. if (_done || id != _emissionId || !_hasValue) { return; diff --git a/src/Primitives.Extensions.Shared/Operators/ThrottleUntilTrueObservable.cs b/src/Primitives.Extensions.Shared/Operators/ThrottleUntilTrueObservable.cs index 0d344b82..04946c35 100644 --- a/src/Primitives.Extensions.Shared/Operators/ThrottleUntilTrueObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ThrottleUntilTrueObservable.cs @@ -15,10 +15,12 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// The source observable. /// The throttle duration. /// The predicate to determine if an element should be emitted immediately or throttled. +/// The sequencer timing throttled emissions; null uses the default sequencer. internal sealed class ThrottleUntilTrueObservable( IObservable source, TimeSpan throttle, - Func predicate) : IObservable + Func predicate, + ISequencer? sequencer = null) : IObservable { /// The source observable. private readonly IObservable _source = InvalidOperationExceptionHelper.Check(source); @@ -29,17 +31,20 @@ internal sealed class ThrottleUntilTrueObservable( /// The predicate to determine if an element should be emitted immediately or throttled. private readonly Func _predicate = InvalidOperationExceptionHelper.Check(predicate); + /// The sequencer timing throttled emissions. + private readonly ISequencer _sequencer = sequencer ?? Sequencer.Default; + /// public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - ThrottleUntilTrueSink sink = new(observer, _throttle, _predicate, Sequencer.Default); + ThrottleUntilTrueSink sink = new(observer, _throttle, _predicate, _sequencer); var subscription = _source.Subscribe(sink); return new DisposableBag(subscription, sink); } - /// Sinks the source observable and throttles elements until a predicate is true. + /// Sink that forwards a value inline when the predicate holds and otherwise after the throttle window. /// The downstream observer. /// The throttle duration. /// The predicate. diff --git a/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs b/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs index b1a3b22d..cdc2e1e2 100644 --- a/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs @@ -11,11 +11,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Resource-bound factory observable that runs an optional -/// against a captured -/// resource, emits , completes, and finally disposes -/// the resource. Replaces the legacy -/// Observable.Using(() => obj, id => Observable.Start(...)) pattern. +/// Resource-bound factory observable that runs an optional against a captured +/// resource, emits , completes, and disposes the resource. /// /// The disposable resource type. /// The resource to use during the operation and dispose at the end. @@ -55,7 +52,7 @@ public IDisposable Subscribe(IObserver observer) "Design", "SST1429:Handle, rethrow, or narrow this catch; an empty catch of the base exception hides failures", Justification = - "A secondary dispose failure during error handling is intentionally swallowed so the primary exception sent to the observer wins; this assembly has no unhandled-exception sink.")] + "A secondary dispose failure is swallowed so the primary exception reaching the observer wins.")] private static void Run(IObserver observer, T resource, Action? action) { try diff --git a/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs b/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs index 768da676..14755c3c 100644 --- a/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs @@ -11,10 +11,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Resource-bound factory observable that runs a -/// against a captured resource, emits the result, -/// completes, and finally disposes the resource. Replaces the legacy -/// Observable.Using(() => obj, id => Observable.Start(() => func(id))) pattern. +/// Resource-bound factory observable that runs a against a captured +/// resource, emits the result, completes, and disposes the resource. /// /// The disposable resource type. /// The result type emitted to the downstream observer. @@ -59,7 +57,7 @@ public IDisposable Subscribe(IObserver observer) "Design", "SST1429:Handle, rethrow, or narrow this catch; an empty catch of the base exception hides failures", Justification = - "A secondary dispose failure during error handling is intentionally swallowed so the primary exception sent to the observer wins; this assembly has no unhandled-exception sink.")] + "A secondary dispose failure is swallowed so the primary exception reaching the observer wins.")] private static void Run(IObserver observer, T resource, Func function) { TResult result; diff --git a/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs b/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs index ae87e75b..214ce2d9 100644 --- a/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs @@ -12,12 +12,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; #endif /// -/// Loops the supplied on the supplied -/// (or inline when no scheduler is provided), emitting -/// after each iteration, for as long as -/// returns true. Replaces the legacy -/// Observable.While(condition, Observable.Start(action, scheduler)) -/// pattern. +/// Loops the supplied on the supplied , or inline when no scheduler is +/// provided, emitting after each iteration for as long as +/// returns true. /// /// The loop predicate. Evaluated before each iteration. /// The action to invoke per iteration. diff --git a/src/Primitives.Extensions.Shared/ReactiveExtensions.cs b/src/Primitives.Extensions.Shared/ReactiveExtensions.cs index 7ad51784..33feaa0b 100644 --- a/src/Primitives.Extensions.Shared/ReactiveExtensions.cs +++ b/src/Primitives.Extensions.Shared/ReactiveExtensions.cs @@ -20,7 +20,7 @@ namespace ReactiveUI.Primitives.Extensions; [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "Existing API")] + Justification = "The SubscribeAsync overloads are named for the asynchronous handler they invoke and return a subscription handle rather than a task.")] public static partial class ReactiveExtensions { /// Default match timeout for regex filters created from string patterns. @@ -42,16 +42,16 @@ public IObservable Start(ISequencer? scheduler) => /// Condition to evaluate. extension(Func condition) { - /// While construct. + /// Runs the action inline, emitting after each iteration for as long as the condition holds. /// Action to execute. /// Observable representing the loop. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable While(Action action) => While(condition, action, null); - /// While construct. + /// Runs the action on the scheduler, emitting after each iteration for as long as the condition holds. /// Action to execute. - /// Scheduler. + /// Scheduler each iteration runs on; null runs inline. /// Observable representing the loop. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable While(Action action, ISequencer? scheduler) => @@ -62,13 +62,13 @@ public IObservable While(Action action, ISequencer? scheduler) => /// The sources. extension(IEnumerable> sources) { - /// Latest values of each sequence are all false. + /// Combines the latest value of every source and reports whether they are all false. /// A sequence that emits true when all latest booleans are false. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable CombineLatestValuesAreAllFalse() => new BooleanReduceObservable(sources, false); - /// Latest values of each sequence are all true. + /// Combines the latest value of every source and reports whether they are all true. /// A sequence that emits true when all latest booleans are true. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable CombineLatestValuesAreAllTrue() => @@ -415,7 +415,7 @@ public IObservable SwitchIfEmpty(IObservable fallback) => [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The name describes the asynchronous handler this overload invokes; it returns a subscription handle rather than a task.")] public IDisposable SubscribeAsync(Func onNext) => new SubscribeAsyncObservable(source, onNext, null, null); @@ -429,7 +429,7 @@ public IDisposable SubscribeAsync(Func onNext) => [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The name describes the asynchronous handler this overload invokes; it returns a subscription handle rather than a task.")] public IDisposable SubscribeAsync(Func onNext, Action onCompleted) => new SubscribeAsyncObservable(source, onNext, null, onCompleted); @@ -443,7 +443,7 @@ public IDisposable SubscribeAsync(Func onNext, Action onCompleted) [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The name describes the asynchronous handler this overload invokes; it returns a subscription handle rather than a task.")] public IDisposable SubscribeAsync(Func onNext, Action onError) => new SubscribeAsyncObservable(source, onNext, onError, null); @@ -458,7 +458,7 @@ public IDisposable SubscribeAsync(Func onNext, Action o [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The name describes the asynchronous handler this overload invokes; it returns a subscription handle rather than a task.")] public IDisposable SubscribeAsync( Func onNext, Action onError, diff --git a/src/Primitives.Shared/Advanced/AfterSignal.cs b/src/Primitives.Shared/Advanced/AfterSignal.cs index 65f681a6..20ef9072 100644 --- a/src/Primitives.Shared/Advanced/AfterSignal.cs +++ b/src/Primitives.Shared/Advanced/AfterSignal.cs @@ -20,12 +20,12 @@ public sealed class AfterSignal : IRequireCurrentThread /// The recurring period after the first tick, when this is a periodic timer. private readonly TimeSpan? _period; - /// The sequencer used to schedule the tick. + /// The sequencer that schedules the tick. private readonly ISequencer _scheduler; /// Initializes a new instance of the class. /// The delay before the single tick. - /// The sequencer used to schedule the tick. + /// The sequencer that schedules the tick. public AfterSignal(TimeSpan dueTime, ISequencer scheduler) { _dueTime = dueTime; @@ -35,7 +35,7 @@ public AfterSignal(TimeSpan dueTime, ISequencer scheduler) /// Initializes a new instance of the class. /// The delay before the first tick. /// The period between subsequent ticks. - /// The sequencer used to schedule ticks. + /// The sequencer that schedules ticks. public AfterSignal(TimeSpan dueTime, TimeSpan period, ISequencer scheduler) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(period, TimeSpan.Zero); diff --git a/src/Primitives.Shared/Advanced/AfterSubscription.cs b/src/Primitives.Shared/Advanced/AfterSubscription.cs index 1b789c87..533b2aab 100644 --- a/src/Primitives.Shared/Advanced/AfterSubscription.cs +++ b/src/Primitives.Shared/Advanced/AfterSubscription.cs @@ -16,7 +16,7 @@ public sealed class AfterSubscription : IDisposable { /// Initializes a new instance of the class. /// The downstream observer. - /// The scheduler used to emit ticks. + /// The scheduler that emits ticks. /// The delay before the first tick. /// The period between subsequent ticks, or for one-shot timers. public AfterSubscription(IObserver observer, ISequencer scheduler, TimeSpan dueTime, TimeSpan? period) @@ -30,7 +30,7 @@ public AfterSubscription(IObserver observer, ISequencer scheduler, TimeSpa /// Gets the downstream observer. private IObserver Observer { get; } - /// Gets the scheduler used to emit ticks. + /// Gets the scheduler that emits ticks. private ISequencer Scheduler { get; } /// Gets the delay before the first tick. diff --git a/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs b/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs index 76b8e231..020b85ba 100644 --- a/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs +++ b/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs @@ -47,7 +47,7 @@ public sealed class AsyncSubscriptionLifetime : IDisposable public void SetSubscription(IDisposable? disposable) => _subscription.Create(disposable ?? EmptyDisposable.Instance); - /// Marks asynchronous setup complete and releases the cancellation source when still owned here. + /// Marks asynchronous setup complete and releases the cancellation source while it is owned here. public void Complete() => _ = TryComplete(); /// @@ -71,7 +71,7 @@ public void Dispose() _cts.Dispose(); } - /// Attempts to mark asynchronous setup complete and release the cancellation source when still owned here. + /// Attempts to mark asynchronous setup complete and release the cancellation source while it is owned here. /// when this call completed the lifetime. internal bool TryComplete() { @@ -100,7 +100,7 @@ private static void CancelIgnoringDisposed(CancellationTokenSource cts) } catch (ObjectDisposedException) { - // Completion can release the CTS concurrently; disposal still continues with the inner subscription. + // Completion can release the CTS concurrently; disposal continues with the inner subscription. } } } diff --git a/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs b/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs index c461fe9f..a2a3e03c 100644 --- a/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs @@ -95,10 +95,9 @@ internal void Release(IDisposable subscription) /// Connects the source outside and publishes or drops the connection. /// The inner source subscription owned by the connecting observer. /// - /// Connecting runs outside the lock so a synchronous source cannot drive user callbacks while the - /// gate is held. A re-entrant or concurrent can drop the subscriber count to - /// zero before the connection is published; in that case the freshly returned connection is orphaned - /// and is disposed here rather than stored. + /// Connecting runs outside the lock so a synchronous source cannot drive user callbacks while the gate is + /// held, which lets a re-entrant or concurrent drain the subscriber count before the + /// connection is published. /// private void ConnectOutsideGate(IDisposable subscription) { diff --git a/src/Primitives.Shared/Advanced/BufferSignal{T}.cs b/src/Primitives.Shared/Advanced/BufferSignal{T}.cs index a8a5117b..80e958b1 100644 --- a/src/Primitives.Shared/Advanced/BufferSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/BufferSignal{T}.cs @@ -16,7 +16,7 @@ public sealed class BufferSignal : IObservable> /// Initializes a new instance of the class. /// The source observable. /// The buffer window duration. - /// The sequencer used to schedule buffer flushes. + /// The sequencer that schedules buffer flushes. /// or is . public BufferSignal(IObservable source, TimeSpan timeSpan, ISequencer sequencer) { @@ -31,7 +31,7 @@ public BufferSignal(IObservable source, TimeSpan timeSpan, ISequencer sequenc /// Gets the buffer window duration. private TimeSpan Window { get; } - /// Gets the sequencer used to schedule buffer flushes. + /// Gets the sequencer that schedules buffer flushes. private ISequencer Scheduler { get; } /// diff --git a/src/Primitives.Shared/Advanced/CatchSignal{T}.cs b/src/Primitives.Shared/Advanced/CatchSignal{T}.cs index 5be0be31..66e650ed 100644 --- a/src/Primitives.Shared/Advanced/CatchSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/CatchSignal{T}.cs @@ -10,75 +10,74 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the CatchSignal class. -/// The T type. +/// Subscribes to each source in turn, moving to the next one whenever a source errors. +/// The value type. internal sealed class CatchSignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The sources tried in order. private readonly IEnumerable> _sources; /// Initializes a new instance of the class. - /// The sources value. + /// The sources to try in order. public CatchSignal(IEnumerable> sources) => _sources = sources; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports that subscription runs on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => true; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes the observer and starts walking the sources. + /// The downstream observer. + /// The disposable that tears the walk down. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, true, SubscribeCore); - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Creates the handler that walks the sources and starts it. + /// The downstream observer. + /// The outer subscription handle. + /// The disposable that tears the walk down. [MethodImpl(MethodImplOptions.AggressiveInlining)] private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) => new Catch(this, observer, cancel).Run(); - /// Represents the Catch class. + /// Walks the source sequence, advancing on each error and forwarding the last error if none succeed. private sealed class Catch : IObserver, IDisposable { - /// Stores state for the signal implementation. + /// The signal supplying the sources. private readonly CatchSignal _parent; - /// Stores the downstream observer. + /// The downstream observer. private readonly IObserver _observer; - /// Executes the new operation. - /// The result. + /// Serializes advancing the enumerator against teardown. private readonly Lock _gate = new(); - /// Stores the upstream subscription. + /// The outer subscription handle released on teardown. private IDisposable? _cancel; /// Disposed latch; 0 when alive, 1 once disposed. private int _disposed; - /// Stores state for the signal implementation. + /// Set under once teardown ran, so no further source is subscribed. private bool _isDisposed; - /// Stores state for the signal implementation. + /// The enumerator over the sources. private IEnumerator>? _e; - /// Stores state for the signal implementation. + /// The slot holding the current source subscription. private SingleReplaceableDisposable? _subscription; - /// Stores state for the signal implementation. + /// The error raised by the most recent source. private Exception? _lastException; - /// Stores state for the signal implementation. + /// The recursive continuation that advances to the next source. private Action? _nextSelf; /// Initializes a new instance of the class. - /// The parent value. - /// The observer value. - /// The cancel value. + /// The signal supplying the sources. + /// The downstream observer. + /// The outer subscription handle. /// is . public Catch(CatchSignal parent, IObserver observer, IDisposable cancel) { @@ -87,8 +86,8 @@ public Catch(CatchSignal parent, IObserver observer, IDisposable cancel) _parent = parent; } - /// Executes the Run operation. - /// The result. + /// Starts the walk on the immediate sequencer. + /// The disposable that releases the enumerator and the current source subscription. public MultipleDisposable Run() { _isDisposed = false; @@ -108,20 +107,20 @@ public MultipleDisposable Run() })); } - /// Executes the OnNext operation. - /// The value. + /// Forwards a value downstream. + /// The value to forward. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(T value) => _observer.OnNext(value); - /// Executes the OnError operation. - /// The error value. + /// Records the error and advances to the next source instead of terminating. + /// The error raised by the current source. public void OnError(Exception error) { _lastException = error; _nextSelf!(); } - /// Executes the OnCompleted operation. + /// Completes downstream and tears the walk down. public void OnCompleted() { try @@ -134,7 +133,7 @@ public void OnCompleted() } } - /// Executes the Dispose operation. + /// Releases the enumerator, the current source subscription and the outer handle. public void Dispose() { _e?.Dispose(); @@ -144,8 +143,8 @@ public void Dispose() _ = WitnessTeardown.Dispose(ref _disposed, ref _cancel); } - /// Executes the RecursiveRun operation. - /// The self value. + /// Subscribes to the next source, or terminates once the sequence is exhausted. + /// The continuation that re-enters this method for the following source. private void RecursiveRun(Action self) { lock (_gate) @@ -172,7 +171,7 @@ private void RecursiveRun(Action self) } } - /// Advances the handler sequence to the next source. Call while holding the gate. + /// Advances the enumerator to the next source. Call while holding . /// The next source, or once the sequence is exhausted. /// The exception the sequence raised, when it raised one. /// when the sequence advanced without raising. @@ -203,7 +202,7 @@ private bool TryMoveToNextSource(out IObservable? next, out Exception? error) } } - /// Forwards an error downstream and tears the handler down. + /// Forwards an error downstream and tears the walk down. /// The error to forward. private void FailAndDispose(Exception error) { diff --git a/src/Primitives.Shared/Advanced/CollectSignal{T}.cs b/src/Primitives.Shared/Advanced/CollectSignal{T}.cs index 7d157de7..f5594754 100644 --- a/src/Primitives.Shared/Advanced/CollectSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/CollectSignal{T}.cs @@ -16,7 +16,7 @@ public sealed class CollectSignal : IObservable> /// Initializes a new instance of the class. /// The source observable. /// The buffer window duration. - /// The sequencer used to schedule buffer flushes. + /// The sequencer that schedules buffer flushes. /// or is . public CollectSignal(IObservable source, TimeSpan timeSpan, ISequencer sequencer) { @@ -31,7 +31,7 @@ public CollectSignal(IObservable source, TimeSpan timeSpan, ISequencer sequen /// Gets the buffer window duration. private TimeSpan TimeSpan { get; } - /// Gets the sequencer used to schedule buffer flushes. + /// Gets the sequencer that schedules buffer flushes. private ISequencer Sequencer { get; } /// diff --git a/src/Primitives.Shared/Advanced/CollectWitness{T}.cs b/src/Primitives.Shared/Advanced/CollectWitness{T}.cs index 0c688716..eaaf6a54 100644 --- a/src/Primitives.Shared/Advanced/CollectWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/CollectWitness{T}.cs @@ -18,7 +18,7 @@ public sealed class CollectWitness : IObserver, IDisposable /// Serializes access to buffered values and terminal state. private readonly Lock _gate = new(); - /// Whether a flush has already been scheduled for the current window. + /// Whether a flush is scheduled for the current window. private bool _flushScheduled; /// Whether the source has terminated. @@ -33,7 +33,7 @@ public CollectWitness(IObserver> observer) => /// Initializes a new instance of the class. /// The downstream observer. /// The buffer window duration. - /// The sequencer used to schedule flushes. + /// The sequencer that schedules flushes. /// or is . public CollectWitness(IObserver> observer, TimeSpan timeSpan, ISequencer sequencer) { @@ -48,7 +48,7 @@ public CollectWitness(IObserver> observer, TimeSpan timeSpan, ISequence /// Gets the buffer window duration. private TimeSpan TimeSpan { get; } - /// Gets the sequencer used to schedule flushes. + /// Gets the sequencer that schedules flushes. private ISequencer? Sequencer { get; } /// Gets the source subscription and scheduled flushes. @@ -132,7 +132,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => Disposables.Add(subscription); - /// Flushes the current window if it still has buffered values. + /// Flushes the current window when it holds buffered values. private void Flush() { var batch = TakeScheduledBatch(); @@ -177,7 +177,7 @@ private bool TryRecord(T value) } } - /// Marks the observer as stopped if it has not already stopped. + /// Transitions the observer to the stopped state. /// when this call stopped the observer. private bool TryMarkStopped() { diff --git a/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs b/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs index 6fea332c..0fc3c76d 100644 --- a/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs @@ -10,37 +10,38 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the CreateSafeSignal class. -/// The T type. +/// Creates a signal from a subscribe delegate whose sink releases the subscription when a downstream +/// OnNext throws. +/// The value type. internal sealed class CreateSafeSignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The delegate invoked for each subscription. private readonly Func, IDisposable> _subscribe; /// Whether subscription must be dispatched through the current-thread sequencer. private readonly bool _isRequiredSubscribeOnCurrentThread; /// Initializes a new instance of the class. - /// The subscribe value. + /// The delegate invoked for each subscription. public CreateSafeSignal(Func, IDisposable> subscribe) => _subscribe = subscribe; /// Initializes a new instance of the class. - /// The subscribe value. - /// The isRequiredSubscribeOnCurrentThread value. + /// The delegate invoked for each subscription. + /// Whether subscription must be dispatched through the current-thread sequencer. public CreateSafeSignal(Func, IDisposable> subscribe, bool isRequiredSubscribeOnCurrentThread) { _subscribe = subscribe; _isRequiredSubscribeOnCurrentThread = isRequiredSubscribeOnCurrentThread; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports whether subscription must be dispatched through the current-thread sequencer. + /// when current-thread dispatch is required. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => _isRequiredSubscribeOnCurrentThread; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Invokes the subscribe delegate with a sink wrapping the observer. + /// The downstream observer. + /// The disposable that releases the subscription. public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -55,10 +56,10 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Invokes the subscribe delegate with a sink that owns . + /// The downstream observer. + /// The outer subscription handle. + /// The disposable returned by the subscribe delegate. private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) { CreateSink sink = new(observer, cancel, true); diff --git a/src/Primitives.Shared/Advanced/CreateSignal{T,TState}.cs b/src/Primitives.Shared/Advanced/CreateSignal{T,TState}.cs index d7c276b7..b67b18fb 100644 --- a/src/Primitives.Shared/Advanced/CreateSignal{T,TState}.cs +++ b/src/Primitives.Shared/Advanced/CreateSignal{T,TState}.cs @@ -10,23 +10,23 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the CreateSignal class. -/// The T type. -/// The TState type. +/// Creates a signal from a subscribe delegate that receives a caller-supplied state value. +/// The value type. +/// The state type handed to the subscribe delegate. internal sealed class CreateSignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The state handed to the subscribe delegate. private readonly TState _state; - /// Stores state for the signal implementation. + /// The delegate invoked for each subscription. private readonly Func, IDisposable> _subscribe; - /// Stores state for the signal implementation. + /// Whether subscription must be dispatched through the current-thread sequencer. private readonly bool _currentThreadRequired; /// Initializes a new instance of the class. - /// The state value. - /// The subscribe value. + /// The state handed to the subscribe delegate. + /// The delegate invoked for each subscription. public CreateSignal(TState state, Func, IDisposable> subscribe) { _state = state; @@ -34,9 +34,9 @@ public CreateSignal(TState state, Func, IDisposable> subscr } /// Initializes a new instance of the class. - /// The state value. - /// The subscribe value. - /// The isRequiredSubscribeOnCurrentThread value. + /// The state handed to the subscribe delegate. + /// The delegate invoked for each subscription. + /// Whether subscription must be dispatched through the current-thread sequencer. public CreateSignal( TState state, Func, IDisposable> subscribe, @@ -47,43 +47,43 @@ public CreateSignal( _currentThreadRequired = isRequiredSubscribeOnCurrentThread; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports whether subscription must be dispatched through the current-thread sequencer. + /// when current-thread dispatch is required. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => _currentThreadRequired; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Invokes the subscribe delegate with the state and a wrapper around the observer. + /// The downstream observer. + /// The disposable that releases the subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, _currentThreadRequired, SubscribeCore); - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Invokes the subscribe delegate with a wrapper that owns . + /// The downstream observer. + /// The outer subscription handle. + /// The disposable returned by the subscribe delegate. private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) { observer = new Create(observer, cancel); return _subscribe(_state, observer) ?? EmptyDisposable.Instance; } - /// Represents the Create class. + /// Forwards notifications downstream and releases the subscription on termination. private sealed class Create : IObserver, IDisposable { - /// Stores the downstream observer. + /// The downstream observer. private readonly IObserver _observer; - /// Stores the upstream subscription. + /// The outer subscription handle released on teardown. private IDisposable? _cancel; /// Disposed latch; 0 when alive, 1 once disposed. private int _disposed; /// Initializes a new instance of the class. - /// The observer value. - /// The cancel value. + /// The downstream observer. + /// The outer subscription handle. /// is . public Create(IObserver observer, IDisposable cancel) { @@ -91,13 +91,13 @@ public Create(IObserver observer, IDisposable cancel) _observer = observer; } - /// Executes the OnNext operation. - /// The value. + /// Forwards a value downstream. + /// The value to forward. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(T value) => _observer.OnNext(value); - /// Executes the OnError operation. - /// The error value. + /// Forwards the error downstream and releases the subscription. + /// The error to forward. public void OnError(Exception error) { try @@ -110,7 +110,7 @@ public void OnError(Exception error) } } - /// Executes the OnCompleted operation. + /// Completes downstream and releases the subscription. public void OnCompleted() { try @@ -123,7 +123,7 @@ public void OnCompleted() } } - /// Executes the Dispose operation. + /// Releases the outer subscription handle once. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => WitnessTeardown.Dispose(ref _disposed, ref _cancel); } diff --git a/src/Primitives.Shared/Advanced/CreateSignal{T}.cs b/src/Primitives.Shared/Advanced/CreateSignal{T}.cs index 6b6f49d9..cd12bbe7 100644 --- a/src/Primitives.Shared/Advanced/CreateSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/CreateSignal{T}.cs @@ -10,37 +10,37 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the CreateSignal class. -/// The T type. +/// Creates a signal from a caller-supplied subscribe delegate. +/// The value type. internal sealed class CreateSignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The delegate invoked for each subscription. private readonly Func, IDisposable> _subscribe; - /// Stores state for the signal implementation. + /// Whether subscription must be dispatched through the current-thread sequencer. private readonly bool _currentThreadRequired; /// Initializes a new instance of the class. - /// The subscribe value. + /// The delegate invoked for each subscription. public CreateSignal(Func, IDisposable> subscribe) => _subscribe = subscribe; /// Initializes a new instance of the class. - /// The subscribe value. - /// The isRequiredSubscribeOnCurrentThread value. + /// The delegate invoked for each subscription. + /// Whether subscription must be dispatched through the current-thread sequencer. public CreateSignal(Func, IDisposable> subscribe, bool isRequiredSubscribeOnCurrentThread) { _subscribe = subscribe; _currentThreadRequired = isRequiredSubscribeOnCurrentThread; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports whether subscription must be dispatched through the current-thread sequencer. + /// when current-thread dispatch is required. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => _currentThreadRequired; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Invokes the subscribe delegate with a sink wrapping the observer. + /// The downstream observer. + /// The disposable that releases the subscription. public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -55,10 +55,10 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Invokes the subscribe delegate with a sink that owns . + /// The downstream observer. + /// The outer subscription handle. + /// The disposable returned by the subscribe delegate. private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) { CreateSink sink = new(observer, cancel, false); diff --git a/src/Primitives.Shared/Advanced/CreateSink{T}.cs b/src/Primitives.Shared/Advanced/CreateSink{T}.cs index 21ebdf29..234c4735 100644 --- a/src/Primitives.Shared/Advanced/CreateSink{T}.cs +++ b/src/Primitives.Shared/Advanced/CreateSink{T}.cs @@ -11,10 +11,9 @@ namespace ReactiveUI.Primitives.Advanced; #endif /// -/// The observer handed to create-style subscription factories: forwards until terminated, owns the factory's -/// cancel resource, and optionally releases it when a downstream OnNext throws (the safe-create contract). -/// The shared sink behind and , usable by any -/// implementation that hands an observer to a caller-supplied subscribe delegate. +/// The observer handed to create-style subscription factories. It forwards notifications until terminated and owns +/// the cancel resource the factory returns; when constructed with disposeOnNextThrow it also releases that +/// resource before rethrowing if a downstream OnNext throws. /// /// The value type. [System.Diagnostics.DebuggerDisplay("CreateSink: Stopped = {_stopped}, Observer = {_observer}")] @@ -60,7 +59,7 @@ public CreateSink(IObserver observer, IDisposable cancel, bool disposeOnNextT _disposeOnNextThrow = disposeOnNextThrow; } - /// Assigns the cancellation resource, releasing it immediately when already stopped. + /// Assigns the cancellation resource, releasing it immediately when the sink has stopped. /// Cancellation resource. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCancel(IDisposable cancel) => diff --git a/src/Primitives.Shared/Advanced/DeferSignal{T}.cs b/src/Primitives.Shared/Advanced/DeferSignal{T}.cs index 91db3726..b0dec20d 100644 --- a/src/Primitives.Shared/Advanced/DeferSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/DeferSignal{T}.cs @@ -10,33 +10,33 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the DeferSignal class. -/// The T type. +/// Invokes a factory per subscription and subscribes the observer to the observable it returns. +/// The value type. internal sealed class DeferSignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The factory invoked for each subscription. private readonly Func> _observableFactory; /// Initializes a new instance of the class. - /// The observableFactory value. + /// The factory invoked for each subscription. public DeferSignal(Func> observableFactory) => _observableFactory = observableFactory; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports that subscription needs no current-thread dispatch. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes the observer to the observable produced for this subscription. + /// The downstream observer. + /// The disposable that releases the subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, false, SubscribeCore); - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Invokes the factory and subscribes, turning a factory throw into an error notification. + /// The downstream observer. + /// The outer subscription handle. + /// The subscription to the produced observable. private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) { observer = new GuardedWitness(observer, cancel); diff --git a/src/Primitives.Shared/Advanced/EmitIfQuietSignal{T}.cs b/src/Primitives.Shared/Advanced/EmitIfQuietSignal{T}.cs index 7d313418..3b037e8a 100644 --- a/src/Primitives.Shared/Advanced/EmitIfQuietSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/EmitIfQuietSignal{T}.cs @@ -16,7 +16,7 @@ public sealed class EmitIfQuietSignal : IObservable /// Initializes a new instance of the class. /// The source observable. /// The quiet period before emitting the latest value. - /// The sequencer used to schedule delayed emissions. + /// The sequencer that schedules delayed emissions. /// or is . public EmitIfQuietSignal(IObservable source, TimeSpan dueTime, ISequencer sequencer) { @@ -31,7 +31,7 @@ public EmitIfQuietSignal(IObservable source, TimeSpan dueTime, ISequencer seq /// Gets the quiet period before emitting the latest value. private TimeSpan DueTime { get; } - /// Gets the sequencer used to schedule delayed emissions. + /// Gets the sequencer that schedules delayed emissions. private ISequencer Sequencer { get; } /// diff --git a/src/Primitives.Shared/Advanced/EmitIfQuietWitness{T}.cs b/src/Primitives.Shared/Advanced/EmitIfQuietWitness{T}.cs index 0aa0e3e9..2db5c71f 100644 --- a/src/Primitives.Shared/Advanced/EmitIfQuietWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/EmitIfQuietWitness{T}.cs @@ -21,7 +21,7 @@ public sealed class EmitIfQuietWitness : IObserver, IDisposable /// The latest observed value. private T? _latest; - /// Monotonic version used to suppress obsolete scheduled emissions. + /// Monotonic version that suppresses obsolete scheduled emissions. private long _version; /// Whether a latest value is pending emission. @@ -33,7 +33,7 @@ public sealed class EmitIfQuietWitness : IObserver, IDisposable /// Initializes a new instance of the class. /// The downstream observer. /// The quiet period before emitting the latest value. - /// The sequencer used to schedule delayed emissions. + /// The sequencer that schedules delayed emissions. /// or is . public EmitIfQuietWitness(IObserver observer, TimeSpan dueTime, ISequencer sequencer) { @@ -48,7 +48,7 @@ public EmitIfQuietWitness(IObserver observer, TimeSpan dueTime, ISequencer se /// Gets the quiet period before emitting the latest value. private TimeSpan DueTime { get; } - /// Gets the sequencer used to schedule delayed emissions. + /// Gets the sequencer that schedules delayed emissions. private ISequencer Sequencer { get; } /// Gets the source subscription and scheduled delayed emissions. @@ -125,7 +125,7 @@ public void OnCompleted() } } - /// Emits the latest value if the scheduled version is still current. + /// Emits the latest value when the scheduled version is the current one. /// The version captured when the emission was scheduled. private void EmitIfLatest(long scheduledVersion) { @@ -158,7 +158,7 @@ private bool TryRecord(T value, out long currentVersion) } } - /// Marks the observer as stopped if it has not already stopped. + /// Transitions the observer to the stopped state. /// when this call stopped the observer. private bool TryMarkStopped() { diff --git a/src/Primitives.Shared/Advanced/EmptySignal{T}.cs b/src/Primitives.Shared/Advanced/EmptySignal{T}.cs index 8451a01f..bf749610 100644 --- a/src/Primitives.Shared/Advanced/EmptySignal{T}.cs +++ b/src/Primitives.Shared/Advanced/EmptySignal{T}.cs @@ -10,34 +10,34 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the EmptySignal class. -/// The T type. +/// Completes without emitting a value, delivering completion on the supplied sequencer. +/// The value type. [System.Diagnostics.DebuggerDisplay("EmptySignal: Scheduler = {_scheduler}")] public sealed class EmptySignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The sequencer that delivers completion. private readonly ISequencer _scheduler; /// Initializes a new instance of the class. - /// The scheduler value. + /// The sequencer that delivers completion. public EmptySignal(ISequencer scheduler) => _scheduler = scheduler; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports that subscription needs no current-thread dispatch. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes the observer and arranges its completion. + /// The downstream observer. + /// The disposable that cancels a completion not yet delivered. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, false, SubscribeCore); - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Completes the observer inline on the immediate sequencer, otherwise schedules the completion. + /// The downstream observer. + /// The outer subscription handle. + /// The disposable that cancels a scheduled completion. private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) { observer = new GuardedWitness(observer, cancel); diff --git a/src/Primitives.Shared/Advanced/EverySignal.cs b/src/Primitives.Shared/Advanced/EverySignal.cs index cb313d55..458ea6ca 100644 --- a/src/Primitives.Shared/Advanced/EverySignal.cs +++ b/src/Primitives.Shared/Advanced/EverySignal.cs @@ -10,19 +10,16 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// -/// Dedicated signal for the interval timer factory (Every), replacing the self-referencing -/// CreateSafe closure with a coordinator that reschedules itself through a method group. -/// +/// Emits an incrementing tick at a fixed interval for the Every factory. /// The interval between ticks. -/// The sequencer used to schedule ticks. +/// The sequencer that schedules ticks. [System.Diagnostics.DebuggerDisplay("EverySignal: Period = {_period}, Scheduler = {_scheduler}")] public sealed class EverySignal(TimeSpan period, ISequencer scheduler) : IRequireCurrentThread { /// The interval between ticks. private readonly TimeSpan _period = period; - /// The sequencer used to schedule ticks. + /// The sequencer that schedules ticks. private readonly ISequencer _scheduler = scheduler; /// @@ -50,13 +47,13 @@ public IDisposable Subscribe(IObserver observer) return subscription; } - /// Reschedules the recurring tick without a captured closure. + /// Emits each tick and re-arms the schedule for the following one. private sealed class EveryCoordinator : IDisposable { /// The downstream observer. private readonly IObserver _observer; - /// The sequencer used to schedule ticks. + /// The sequencer that schedules ticks. private readonly ISequencer _scheduler; /// The interval between ticks. @@ -73,7 +70,7 @@ private sealed class EveryCoordinator : IDisposable /// Initializes a new instance of the class. /// The downstream observer. - /// The sequencer used to schedule ticks. + /// The sequencer that schedules ticks. /// The interval between ticks. internal EveryCoordinator(IObserver observer, ISequencer scheduler, TimeSpan period) { @@ -100,14 +97,9 @@ internal EveryCoordinator Run() private void ScheduleNext() => TimerSlot.Arm(_slot, _scheduler, _period, _tickAction); /// Emits the current tick and reschedules unless cancelled. - /// - /// Disposal is tested before the emit as well as after it. A cancelled subscription must not deliver another - /// value, and the sequencer's own cancellation check happens before the item is invoked, not before the - /// observer is called. The second test covers an observer that disposes the subscription from inside - /// , which must stop the recurring schedule rather than re-arm it. - /// private void Tick() { + // The sequencer checks cancellation before invoking this item, not before the observer is called. if (_slot.IsDisposed) { return; @@ -116,6 +108,8 @@ private void Tick() var tick = _tick; _tick++; _observer.OnNext(tick); + + // An observer that disposes from inside OnNext must stop the schedule rather than re-arm it. if (_slot.IsDisposed) { return; diff --git a/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs b/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs index 2aba4628..f96303f0 100644 --- a/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs +++ b/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs @@ -22,7 +22,7 @@ public sealed class ExpireCoordinator : IObserver, IDisposable /// The timeout period. private readonly TimeSpan _dueTime; - /// The sequencer used to schedule the timeout. + /// The sequencer that schedules the timeout. private readonly ISequencer _sequencer; /// The downstream observer. @@ -32,35 +32,33 @@ public sealed class ExpireCoordinator : IObserver, IDisposable [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA2213:Disposable fields should be disposed", - Justification = - "Disposed via the thread-safe Interlocked.Exchange teardown in Dispose; CA2213 does not recognize disposal of a field through Interlocked.Exchange.")] + Justification = "Disposed through Interlocked.Exchange in Dispose.")] private IDisposable? _subscription; /// The active timeout timer. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA2213:Disposable fields should be disposed", - Justification = - "Disposed via the thread-safe Interlocked.Exchange teardown in Dispose; CA2213 does not recognize disposal of a field through Interlocked.Exchange.")] + Justification = "Disposed through Interlocked.Exchange in Dispose.")] private IDisposable? _timer; /// A value indicating whether the timeout or source has terminated. private int _done; - /// Monotonic version used to suppress timeouts superseded by a newer value. + /// Monotonic version that suppresses timeouts superseded by a newer value. private long _epoch; /// - /// The instant, on the sequencer's own clock, at which the current inactivity window closes. Read and written - /// under . Starts at so a window that has not been - /// published yet can never expire a value. + /// The instant on the sequencer's clock at which the current inactivity window closes, read and written under + /// . It starts at so an unpublished window never + /// expires a value. /// private DateTimeOffset _deadline = DateTimeOffset.MaxValue; /// Initializes a new instance of the class. /// The source observable. /// The timeout period. - /// The sequencer used to schedule the timeout. + /// The sequencer that schedules the timeout. /// The downstream observer. public ExpireCoordinator(IObservable source, TimeSpan dueTime, ISequencer sequencer, IObserver observer) { @@ -140,11 +138,9 @@ public void OnError(Exception error) /// /// - /// A value is on time only when it arrives before the current inactivity window closes, which is a question for - /// the sequencer's clock — not for whether the armed timer has run yet. The timer is dispatched by the sequencer, - /// and a thread-pool sequencer whose pool is saturated can dispatch it arbitrarily late while a source on another - /// thread keeps producing. Forwarding a value in that gap would deliver a value the operator has already promised - /// to time out, so a value that arrives after its deadline expires the sequence here instead. + /// A value is on time only when it arrives before the inactivity window closes on the sequencer's clock, not + /// merely before the armed timer has run. A value that arrives past its deadline — which a saturated thread-pool + /// sequencer can allow — terminates the sequence with instead of being forwarded. /// public void OnNext(T value) { @@ -204,10 +200,10 @@ public ExpireCoordinator Run() } /// Schedules a fresh inactivity timer for the given epoch and discards the in-flight one. - /// The version this timer must still match to fire. - /// Scheduled outside the gate to avoid reentrant acquisition on a synchronous - /// sequencer; the publish is re-checked under the gate so a timer never survives a terminal notification, and - /// so a superseded arm cannot publish its older deadline and timer over a newer value's. + /// The version this timer must match to fire. + /// Scheduling happens outside the gate so a synchronous sequencer cannot re-enter . + /// The publish is re-checked under the gate, so neither a terminal notification nor a newer value's window can be + /// overwritten by a superseded arm. private void ArmTimer(long epoch) { var deadline = Deadline(); @@ -232,13 +228,11 @@ private void ArmTimer(long epoch) previous?.Dispose(); } - /// Computes the instant the inactivity window opened now would close at. + /// Computes the closing instant of an inactivity window opened at the current time. /// The deadline on the sequencer's clock, saturated instead of overflowing. /// - /// A due time that normalizes to zero is scheduled as immediate work rather than timed work, so it has no clock - /// window: the timeout is ordered by the sequencer's queue, and a synchronous value that arrives before the queue - /// drains still wins. That branch mirrors the scheduling extension's own zero-due-time path, keeping the deadline - /// in lockstep with how the timer was actually scheduled. Only a positive due time opens a window on the clock. + /// A due time that normalizes to zero is queued as immediate work, so it opens no clock window and a synchronous + /// value arriving before the queue drains wins; only a positive due time yields a real deadline. /// private DateTimeOffset Deadline() { @@ -252,7 +246,7 @@ private DateTimeOffset Deadline() return DateTimeOffset.MaxValue - now <= dueTime ? DateTimeOffset.MaxValue : now + dueTime; } - /// Emits the timeout error when the firing timer is still current. + /// Emits the timeout error when the firing timer is the current one. /// The version captured when the firing timer was armed. /// An empty disposable. private EmptyDisposable EmitTimeout(long epoch) diff --git a/src/Primitives.Shared/Advanced/ExpireSignal{T}.cs b/src/Primitives.Shared/Advanced/ExpireSignal{T}.cs index 9011b5c1..a4c31459 100644 --- a/src/Primitives.Shared/Advanced/ExpireSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/ExpireSignal{T}.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Timeout signal with a direct subscription path. +/// Terminates with a when the source stays quiet longer than the timeout period. /// The source value type. [System.Diagnostics.DebuggerDisplay("ExpireSignal: DueTime = {_dueTime}, Source = {_source}")] public sealed class ExpireSignal : IRequireCurrentThread @@ -19,13 +19,13 @@ public sealed class ExpireSignal : IRequireCurrentThread /// The timeout period. private readonly TimeSpan _dueTime; - /// The sequencer used to schedule the timeout. + /// The sequencer that schedules the timeout. private readonly ISequencer _sequencer; /// Initializes a new instance of the class. /// The source observable. /// The timeout period. - /// The sequencer used to schedule the timeout. + /// The sequencer that schedules the timeout. public ExpireSignal(IObservable source, TimeSpan dueTime, ISequencer sequencer) { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/Primitives.Shared/Advanced/FinallySignal{T}.cs b/src/Primitives.Shared/Advanced/FinallySignal{T}.cs index 8e739889..7a339c83 100644 --- a/src/Primitives.Shared/Advanced/FinallySignal{T}.cs +++ b/src/Primitives.Shared/Advanced/FinallySignal{T}.cs @@ -10,58 +10,58 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the FinallySignal class. -/// The T type. -/// The source value. -/// The finallyAction value. +/// Runs an action once the subscription ends, whether it terminated or was disposed. +/// The value type. +/// The source observable. +/// The action run when the subscription ends. [System.Diagnostics.DebuggerDisplay("FinallySignal: Source = {_source}")] public sealed class FinallySignal(IObservable source, Action finallyAction) : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The source observable. private readonly IObservable _source = source; - /// Stores state for the signal implementation. + /// The action run when the subscription ends. private readonly Action _finallyAction = finallyAction; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports that subscription runs on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => true; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes the observer and attaches the end-of-subscription action. + /// The downstream observer. + /// The disposable that releases the subscription and runs the action. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, true, SubscribeCore); - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Creates the handler that forwards notifications and owns the end-of-subscription action. + /// The downstream observer. + /// The outer subscription handle. + /// The disposable that releases the subscription and runs the action. [MethodImpl(MethodImplOptions.AggressiveInlining)] private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) => new Finally(this, observer, cancel).Run(); - /// Represents the Finally class. + /// Forwards notifications downstream and pairs the subscription with the end-of-subscription action. private sealed class Finally : IObserver, IDisposable { - /// Stores state for the signal implementation. + /// The signal supplying the source and the action. private readonly FinallySignal _parent; - /// Stores the downstream observer. + /// The downstream observer. private readonly IObserver _observer; - /// Stores the upstream subscription. + /// The outer subscription handle released on teardown. private IDisposable? _cancel; /// Disposed latch; 0 when alive, 1 once disposed. private int _disposed; /// Initializes a new instance of the class. - /// The parent value. - /// The observer value. - /// The cancel value. + /// The signal supplying the source and the action. + /// The downstream observer. + /// The outer subscription handle. /// is . public Finally(FinallySignal parent, IObserver observer, IDisposable cancel) { @@ -70,8 +70,8 @@ public Finally(FinallySignal parent, IObserver observer, IDisposable cance _parent = parent; } - /// Executes the Run operation. - /// The result. + /// Subscribes to the source, running the action immediately if subscription throws. + /// The disposable that releases the source subscription and then runs the action. public MultipleDisposable Run() { IDisposable subscription; @@ -88,13 +88,13 @@ public MultipleDisposable Run() return new(subscription, new ActionDisposable(() => _parent._finallyAction())); } - /// Executes the OnNext operation. - /// The value. + /// Forwards a value downstream. + /// The value to forward. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(T value) => _observer.OnNext(value); - /// Executes the OnError operation. - /// The error value. + /// Forwards the error downstream and releases the subscription. + /// The error to forward. public void OnError(Exception error) { try @@ -107,7 +107,7 @@ public void OnError(Exception error) } } - /// Executes the OnCompleted operation. + /// Completes downstream and releases the subscription. public void OnCompleted() { try @@ -120,7 +120,7 @@ public void OnCompleted() } } - /// Executes the Dispose operation. + /// Releases the outer subscription handle once. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => WitnessTeardown.Dispose(ref _disposed, ref _cancel); } diff --git a/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs b/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs index 615fd62e..8bf1e500 100644 --- a/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs +++ b/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs @@ -110,7 +110,7 @@ internal IDisposable Start() return this; } - /// Forwards a task that has already reached a terminal state. + /// Forwards a task that has reached a terminal state. /// The task to observe. /// The downstream observer. /// The subscription lifetime. @@ -120,7 +120,7 @@ internal IDisposable Start() [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = "Synchronous read is limited to the already-completed task fast path.")] + Justification = "Synchronous read is limited to a task in a terminal state.")] private static bool TryCompleteSynchronously( Task task, IObserver observer, @@ -145,7 +145,7 @@ private static bool TryCompleteSynchronously( : task.IsFaulted && FaultSynchronously(task, observer, lifetime, externalCancellation, linkedSource); } - /// Forwards an already-successful task result. + /// Forwards a successful task result. /// The task result. /// The downstream observer. /// The subscription lifetime. @@ -171,7 +171,7 @@ private static bool CompleteSynchronously( return true; } - /// Forwards an already-canceled task result. + /// Forwards a canceled task result. /// The task to observe. /// The downstream observer. /// The subscription lifetime. @@ -202,7 +202,7 @@ private static bool CancelSynchronously( return true; } - /// Forwards an already-faulted task result. + /// Forwards a faulted task result. /// The task to observe. /// The downstream observer. /// The subscription lifetime. diff --git a/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs b/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs index 7f78774d..fb04d930 100644 --- a/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs +++ b/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs @@ -9,9 +9,9 @@ namespace ReactiveUI.Primitives.Advanced; #endif /// -/// Bridges an event whose handler is built by a caller-supplied conversion, so the handler can do -/// synchronous work inside the event before the notification is published downstream. Supplying the -/// conversion removes the need to derive a delegate reflectively, keeping the bridge trim- and AOT-safe. +/// Bridges an event whose handler is built by a caller-supplied conversion, letting the handler do synchronous work +/// inside the event before the notification is published downstream. The conversion replaces reflective delegate +/// creation, so the bridge is trim- and AOT-safe. /// /// The delegate type used by the event. /// The notification callback type handed to the conversion. diff --git a/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs b/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs index 80542e27..0f283e61 100644 --- a/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs @@ -12,17 +12,16 @@ namespace ReactiveUI.Primitives.Advanced; /// /// Forwarding observer that releases its upstream cancel resource when the sequence terminates or a downstream -/// OnNext throws. The shared guard behind the scheduled factory signals (Empty, Return, Throw, Defer), -/// usable by any signal implementation that needs terminate-and-release semantics around a downstream observer. +/// OnNext throws, rethrowing the latter after release. /// /// The value type. [System.Diagnostics.DebuggerDisplay("GuardedWitness: Disposed = {_disposed}, Observer = {_observer}")] public sealed class GuardedWitness : IObserver, IDisposable { - /// Stores the downstream observer. + /// The downstream observer. private readonly IObserver _observer; - /// Stores the upstream subscription. + /// The upstream cancel resource released on termination. private IDisposable? _cancel; /// Disposed latch; 0 when alive, 1 once disposed. diff --git a/src/Primitives.Shared/Advanced/ImmutableReturnRxVoidSignal.cs b/src/Primitives.Shared/Advanced/ImmutableReturnRxVoidSignal.cs index fc74becb..2f08e582 100644 --- a/src/Primitives.Shared/Advanced/ImmutableReturnRxVoidSignal.cs +++ b/src/Primitives.Shared/Advanced/ImmutableReturnRxVoidSignal.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// An allocation-free observable that emits a single value and then completes; the concrete singleton backing for the RxVoid emit path. +/// Emits inline and completes, without allocating a subscription. public sealed class ImmutableReturnRxVoidSignal : IRequireCurrentThread, IInlineSignal { /// The shared singleton instance. @@ -21,14 +21,14 @@ private ImmutableReturnRxVoidSignal() { } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports that subscription needs no current-thread dispatch. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits and completes before returning. + /// The downstream observer. + /// An empty disposable; the sequence has ended and there is nothing to cancel. public IDisposable Subscribe(IObserver observer) { observer.OnNext(RxVoid.Default); @@ -36,11 +36,11 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes with and then . + /// The value callback. + /// The error callback, which is never invoked. + /// The completion callback. + /// An empty disposable; the sequence has ended and there is nothing to cancel. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { onNext(RxVoid.Default); diff --git a/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs b/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs index 27fe3ebe..4ebd7d17 100644 --- a/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs +++ b/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs @@ -48,11 +48,10 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // The first value settles this operator, so it must be able to dispose the source as soon as one arrives. - // A current-thread source runs its work on the trampoline of whichever call enters it first; if that call is - // the source's own Subscribe, the source drains the trampoline before the sink is handed the subscription, - // and an endless source therefore never stops. Entering the trampoline here means the source only queues its - // first tick and returns, so the sink owns the subscription before that tick is delivered. + // The first value settles this operator, so the sink must own the subscription before any value arrives. A + // current-thread source drains the trampoline inside whichever call enters it first, so letting the source's + // own Subscribe enter it would deliver values before the sink holds the subscription, and an endless source + // would never stop. Entering the trampoline here leaves the source only able to queue its first tick. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/Advanced/IsEmptyWitness{T}.cs b/src/Primitives.Shared/Advanced/IsEmptyWitness{T}.cs index e844fb89..640df4ca 100644 --- a/src/Primitives.Shared/Advanced/IsEmptyWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/IsEmptyWitness{T}.cs @@ -15,7 +15,7 @@ namespace ReactiveUI.Primitives.Advanced; [System.Diagnostics.DebuggerDisplay("IsEmptyWitness: Stopped = {_stopped}, Subscription = {Subscription}")] public sealed class IsEmptyWitness : IObserver, IDisposable { - /// Stores the stopped flag for interlocked/ref helper calls. + /// Non-zero once the result has been emitted or the sink disposed. private int _stopped; /// Initializes a new instance of the class. diff --git a/src/Primitives.Shared/Advanced/LoopSignal{T}.cs b/src/Primitives.Shared/Advanced/LoopSignal{T}.cs index 23c50a2d..4fadc5f8 100644 --- a/src/Primitives.Shared/Advanced/LoopSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/LoopSignal{T}.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents an infinite repetition signal. +/// Emits the same value endlessly until the subscription is disposed. /// The value type. [System.Diagnostics.DebuggerDisplay("LoopSignal: Value = {Value}")] public sealed class LoopSignal : IRequireCurrentThread @@ -29,14 +29,14 @@ public LoopSignal(T value) /// Gets the repeated value. internal T Value { get; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports that subscription runs on the current-thread sequencer. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => _currentThreadRequired; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes the observer and starts the endless emission loop. + /// The downstream observer. + /// The disposable that stops the loop. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, _currentThreadRequired, SubscribeCore); @@ -47,16 +47,15 @@ public IDisposable Subscribe(IObserver observer) => private static bool IsDisposed(IDisposable disposable) => disposable is IsDisposed state && state.IsDisposed; - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Schedules the recursive emission loop, which stops as soon as the handle is disposed. + /// The downstream observer. + /// The outer subscription handle. + /// The disposable that cancels the scheduled loop. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Allocations", "PSH1011", - Justification = - "No state-taking recursive Schedule overload exists; needs new scheduling API or a rule exclusion.")] + Justification = "No state-taking recursive Schedule overload exists.")] private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) => Sequencer.CurrentThread.Schedule(self => { diff --git a/src/Primitives.Shared/Advanced/MapIndexedSignal{TSource,TResult}.cs b/src/Primitives.Shared/Advanced/MapIndexedSignal{TSource,TResult}.cs index 1b2362dd..182c2070 100644 --- a/src/Primitives.Shared/Advanced/MapIndexedSignal{TSource,TResult}.cs +++ b/src/Primitives.Shared/Advanced/MapIndexedSignal{TSource,TResult}.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Indexed map signal. +/// Projects each source value together with its zero-based position in the sequence. /// The source value type. /// The projected value type. /// The source observable. diff --git a/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs b/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs index 407d1e5a..2b8b016b 100644 --- a/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs +++ b/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs @@ -85,8 +85,8 @@ public void OnSource(IObservable? source) } // A source is free to signal completion more than once. Latch per inner so a repeat cannot decrement - // the active count a second time - that would drop the count on behalf of a sibling that is still - // running and complete the merge early, losing everything the sibling had left to produce. + // the active count on behalf of a running sibling, which would complete the merge early and drop + // everything that sibling had left to produce. var completed = 0; Subscriptions.Add(source.Subscribe( OnInnerNext, diff --git a/src/Primitives.Shared/Advanced/MergeSignal{T}.cs b/src/Primitives.Shared/Advanced/MergeSignal{T}.cs index b2c94a23..27c3eb03 100644 --- a/src/Primitives.Shared/Advanced/MergeSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/MergeSignal{T}.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Concrete signal for Rx-named merge overloads. +/// Merges concurrent sources into one sequence, optionally bounding the active subscription count. /// The value type. [System.Diagnostics.DebuggerDisplay("MergeSignal: Sources = {Sources}, MaxConcurrent = {MaxConcurrent}")] public sealed class MergeSignal : IObservable diff --git a/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs b/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs index 8d5f6f3f..52b55198 100644 --- a/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs +++ b/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs @@ -11,9 +11,9 @@ namespace ReactiveUI.Primitives.Advanced; #endif /// -/// Dedicated cold signal for Recover/Resume (catch a typed error and switch to a -/// handler-selected sequence). Replaces the witness-framework subject with a lightweight sink that -/// holds its source and fallback subscriptions in two interlocked slots, with no composite disposable. +/// Cold signal that mirrors the source and, on an error of type , switches to +/// the sequence the handler selects for it. Any other error, and an error thrown by the handler itself, is +/// forwarded downstream unchanged. /// /// The value type. /// The handled exception type. diff --git a/src/Primitives.Shared/Advanced/RepeatSourceCoordinator{T}.cs b/src/Primitives.Shared/Advanced/RepeatSourceCoordinator{T}.cs index a52696e0..07353aed 100644 --- a/src/Primitives.Shared/Advanced/RepeatSourceCoordinator{T}.cs +++ b/src/Primitives.Shared/Advanced/RepeatSourceCoordinator{T}.cs @@ -25,7 +25,7 @@ public sealed class RepeatSourceCoordinator : IDisposable /// The active source subscription or queued resubscription. private readonly SingleReplaceableDisposable _active = new(); - /// Guards synchronous completion while a subscription is still being assigned. + /// Guards completion raised synchronously while a subscription is being assigned. private readonly Lock _gate = new(); /// The remaining number of finite subscriptions. diff --git a/src/Primitives.Shared/Advanced/RepeatSourceWitness{T}.cs b/src/Primitives.Shared/Advanced/RepeatSourceWitness{T}.cs index 3e0f1725..2725a093 100644 --- a/src/Primitives.Shared/Advanced/RepeatSourceWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/RepeatSourceWitness{T}.cs @@ -19,7 +19,7 @@ public sealed class RepeatSourceWitness : IObserver /// The source subscription generation. private readonly int _generation; - /// Tracks whether this source subscription has already terminated. + /// Latches to 1 once this source subscription has terminated. private int _terminated; /// Initializes a new instance of the class. diff --git a/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs b/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs index d3e9f7d9..3ce907c8 100644 --- a/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs @@ -11,9 +11,8 @@ namespace ReactiveUI.Primitives.Advanced; #endif /// -/// Dedicated cold signal for Resume (continue with a fixed fallback sequence after any error). Holds the -/// fallback observable directly so no per-subscription closure is allocated, mirroring the slot-based subscription -/// management of . +/// Cold signal that mirrors the source and, when it produces any error, subscribes the fallback sequence in its +/// place. The error is not forwarded downstream. /// /// The value type. /// The source observable. diff --git a/src/Primitives.Shared/Advanced/ReturnSignal{T}.cs b/src/Primitives.Shared/Advanced/ReturnSignal{T}.cs index 13658ac5..5ea4ccb1 100644 --- a/src/Primitives.Shared/Advanced/ReturnSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/ReturnSignal{T}.cs @@ -10,23 +10,23 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// An observable that emits a single value then completes on the supplied scheduler; the concrete backing for the scheduled emit path. +/// Emits a single value and then completes, on the supplied sequencer. /// The emitted value type. [System.Diagnostics.DebuggerDisplay("ReturnSignal: Value = {_value}, Scheduler = {_scheduler}")] public sealed class ReturnSignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The value to emit. private readonly T _value; - /// Stores state for the signal implementation. + /// The sequencer that emits the value and completion. private readonly ISequencer _scheduler; - /// Stores state for the signal implementation. + /// Whether subscription must be dispatched through the current-thread sequencer. private readonly bool _currentThreadRequired; /// Initializes a new instance of the class. - /// The value. - /// The scheduler value. + /// The value to emit. + /// The sequencer that emits the value and completion. public ReturnSignal(T value, ISequencer scheduler) { _value = value; @@ -34,22 +34,22 @@ public ReturnSignal(T value, ISequencer scheduler) _currentThreadRequired = scheduler == Sequencer.CurrentThread; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Gets whether subscription has to be dispatched through the current-thread sequencer. + /// when the supplied sequencer is the current-thread sequencer. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => _currentThreadRequired; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes an observer that receives the value followed by completion. + /// The downstream observer. + /// A disposable that cancels the emission when it has not run yet. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, _currentThreadRequired, SubscribeCore); - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Emits the value and completion inline for the immediate sequencer, otherwise on the sequencer. + /// The downstream observer. + /// The subscription handle the guard checks before forwarding. + /// The disposable that cancels the scheduled emission. private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) { observer = new GuardedWitness(observer, cancel); diff --git a/src/Primitives.Shared/Advanced/ScheduledEnumerableSignal{T}.cs b/src/Primitives.Shared/Advanced/ScheduledEnumerableSignal{T}.cs index 94fc4418..c13b942d 100644 --- a/src/Primitives.Shared/Advanced/ScheduledEnumerableSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/ScheduledEnumerableSignal{T}.cs @@ -8,14 +8,14 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Scheduled enumerable-backed signal used by observable conversion overloads. +/// Emits the values of an enumerable on the supplied sequencer, then completes. /// The value type. [System.Diagnostics.DebuggerDisplay("ScheduledEnumerableSignal: Values = {Values}, Scheduler = {Scheduler}")] public sealed class ScheduledEnumerableSignal : IObservable { /// Initializes a new instance of the class. /// The values to emit. - /// The scheduler used to enumerate and emit the values. + /// The sequencer that enumerates and emits the values. public ScheduledEnumerableSignal(IEnumerable values, ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(values); @@ -29,7 +29,7 @@ public ScheduledEnumerableSignal(IEnumerable values, ISequencer scheduler) /// Gets the values to emit. private IEnumerable Values { get; } - /// Gets the scheduler used to enumerate and emit the values. + /// Gets the sequencer that enumerates and emits the values. private ISequencer Scheduler { get; } /// diff --git a/src/Primitives.Shared/Advanced/SequenceSignal.cs b/src/Primitives.Shared/Advanced/SequenceSignal.cs index f75ef73c..899143a4 100644 --- a/src/Primitives.Shared/Advanced/SequenceSignal.cs +++ b/src/Primitives.Shared/Advanced/SequenceSignal.cs @@ -15,7 +15,7 @@ public sealed class SequenceSignal : IRequireCurrentThread /// Initializes a new instance of the class. /// The first value to emit. /// The number of values to emit. - /// The scheduler used to emit values. + /// The sequencer that emits the values. public SequenceSignal(int start, int count, ISequencer scheduler) { Start = start; @@ -29,7 +29,7 @@ public SequenceSignal(int start, int count, ISequencer scheduler) /// Gets the number of values to emit. private int Count { get; } - /// Gets the scheduler used to emit values. + /// Gets the sequencer that emits the values. private ISequencer Scheduler { get; } /// diff --git a/src/Primitives.Shared/Advanced/SparkWitness.cs b/src/Primitives.Shared/Advanced/SparkWitness.cs index dfff98cd..dbbb1c34 100644 --- a/src/Primitives.Shared/Advanced/SparkWitness.cs +++ b/src/Primitives.Shared/Advanced/SparkWitness.cs @@ -37,7 +37,7 @@ public sealed class SparkWitness : IObserver, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, Spark.CreateOnCompleted(), this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when the slot is occupied. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/Primitives.Shared/Advanced/StartSignal.cs b/src/Primitives.Shared/Advanced/StartSignal.cs index 5a70166b..a6d89a1d 100644 --- a/src/Primitives.Shared/Advanced/StartSignal.cs +++ b/src/Primitives.Shared/Advanced/StartSignal.cs @@ -14,7 +14,7 @@ public sealed class StartSignal : IRequireCurrentThread { /// Initializes a new instance of the class. /// The action to run. - /// The scheduler used to run the action. + /// The sequencer that runs the action. public StartSignal(Action action, ISequencer scheduler) { Action = action; @@ -24,7 +24,7 @@ public StartSignal(Action action, ISequencer scheduler) /// Gets the action to run. private Action Action { get; } - /// Gets the scheduler used to run the action. + /// Gets the sequencer that runs the action. private ISequencer Scheduler { get; } /// diff --git a/src/Primitives.Shared/Advanced/StartSignal{T}.cs b/src/Primitives.Shared/Advanced/StartSignal{T}.cs index 3280e555..e1f4141d 100644 --- a/src/Primitives.Shared/Advanced/StartSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/StartSignal{T}.cs @@ -15,7 +15,7 @@ public sealed class StartSignal : IRequireCurrentThread { /// Initializes a new instance of the class. /// The function to run. - /// The scheduler used to run the function. + /// The sequencer that runs the function. public StartSignal(Func function, ISequencer scheduler) { Function = function; @@ -25,7 +25,7 @@ public StartSignal(Func function, ISequencer scheduler) /// Gets the function to run. private Func Function { get; } - /// Gets the scheduler used to run the function. + /// Gets the sequencer that runs the function. private ISequencer Scheduler { get; } /// diff --git a/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs b/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs index 19f4d726..5e70b6d2 100644 --- a/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs +++ b/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs @@ -9,16 +9,13 @@ namespace ReactiveUI.Primitives.Advanced; #endif /// -/// Shared subscription-time scheduling for advanced signals. A cold signal that must observe the -/// current-thread sequencer cannot simply subscribe inline: when the current-thread sequencer is already -/// draining work, the subscription has to be queued behind it so the emissions arrive in trampoline order. -/// The two shapes below capture that dance once so each signal keeps only its own Run body. +/// Subscription-time scheduling shared by advanced signals: a signal that observes the current-thread +/// sequencer has to queue behind it while it is draining work, so emissions arrive in trampoline order. /// internal static class SubscriptionScheduling { /// - /// Subscribes immediately when the current-thread sequencer is idle, otherwise queues the subscription - /// on it and hands back a placeholder that is filled in once the subscription actually runs. + /// Subscribes inline when the current-thread sequencer is idle, otherwise queues the subscription on it. /// /// The type of the state passed to the subscribe callback. /// The state carried to . @@ -44,13 +41,13 @@ internal static IDisposable OnCurrentThread(TState state, Func /// Runs on : inline for the immediate sequencer, and - /// queued behind the current-thread sequencer when that one is the target and is already draining work. + /// queued behind the current-thread sequencer when that one is the target and is draining work. /// /// The type of the state passed to the scheduled work. /// The sequencer that runs the work. /// The state carried to . /// The work to run, which emits to the downstream observer. - /// The disposable used to cancel the scheduled work (best effort). + /// A disposable that cancels the scheduled work, best effort. internal static IDisposable RunOn(ISequencer sequencer, TState state, Action run) { if (sequencer == Sequencer.Immediate) diff --git a/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs b/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs index c9a4101c..27a9b1aa 100644 --- a/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs +++ b/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs @@ -14,8 +14,8 @@ namespace ReactiveUI.Primitives.Advanced; /// The source element type. /// The element type of the projected inner observables. /// -/// Fuses the projection into the switch: one object and one observer hop, where a projection followed by a -/// separate switch costs two of each and an intermediate sequence of observables. +/// The projection is fused into the switch, so it costs one object and one observer hop instead of the two of +/// each, plus an intermediate sequence of observables, that a separate projection and switch cost. /// [System.Diagnostics.DebuggerDisplay("SwitchMapSignal: Source = {_source}, SkipNullSources = {_skipNullSources}")] public sealed class SwitchMapSignal : IObservable diff --git a/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs b/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs index c10b1e46..d4c5b23f 100644 --- a/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs @@ -80,12 +80,10 @@ private void OnSource(IObservable source) error => OnError(current, error), () => OnCompleted(current)); - // Subscribing can push a value downstream synchronously, and a downstream handler is free to feed the - // outer source again. That re-enters OnSource, installs a newer generation, and only then returns here. - // Installing unconditionally at that point would replace the newer subscription with this stale one and - // dispose it, leaving a subscription whose notifications are all filtered out by version - the sequence - // would then never produce another value nor complete. Only the generation that is still current may - // occupy the slot; a superseded one disposes itself, outside the gate. + // Subscribing can push a value downstream synchronously, and that handler is free to feed the outer + // source again, re-entering OnSource and installing a newer generation before this call returns. Only + // the current generation may occupy the slot: installing a superseded subscription would dispose the + // newer one and leave a sequence whose notifications are all filtered out by version. var superseded = false; lock (_gate) { diff --git a/src/Primitives.Shared/Advanced/SynchronizeObjectSignal{T}.cs b/src/Primitives.Shared/Advanced/SynchronizeObjectSignal{T}.cs index 7629a5ae..dcdaaa7b 100644 --- a/src/Primitives.Shared/Advanced/SynchronizeObjectSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/SynchronizeObjectSignal{T}.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Object-gated Synchronize compatibility signal. +/// Serializes source notifications behind a caller-supplied object gate. /// The value type. /// The source observable. /// The gate shared across subscriptions and other synchronized sequences. diff --git a/src/Primitives.Shared/Advanced/TaskAnyWitness{T}.cs b/src/Primitives.Shared/Advanced/TaskAnyWitness{T}.cs index e79de285..a4eb58d0 100644 --- a/src/Primitives.Shared/Advanced/TaskAnyWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/TaskAnyWitness{T}.cs @@ -34,12 +34,12 @@ public sealed class TaskAnyWitness : IObserver, IDisposable private int _stopped; /// Initializes a new instance of the class. - /// The token used to cancel the task and dispose the subscription. + /// The token that cancels the task and disposes the subscription. public TaskAnyWitness(CancellationToken cancellationToken) => _cancellationToken = cancellationToken; /// Initializes a new instance of the class. /// The predicate. - /// The token used to cancel the task and dispose the subscription. + /// The token that cancels the task and disposes the subscription. public TaskAnyWitness(Func predicate, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(predicate); diff --git a/src/Primitives.Shared/Advanced/TaskChainCoordinatorState.cs b/src/Primitives.Shared/Advanced/TaskChainCoordinatorState.cs index 51d7fe5f..fbb0c385 100644 --- a/src/Primitives.Shared/Advanced/TaskChainCoordinatorState.cs +++ b/src/Primitives.Shared/Advanced/TaskChainCoordinatorState.cs @@ -11,11 +11,11 @@ namespace ReactiveUI.Primitives.Advanced; /// State transitions for . internal static class TaskChainCoordinatorState { - /// Marks the active inner source complete and drains when the coordinator has not already stopped. + /// Marks the active inner source complete and drains the coordinator unless it has stopped. /// The task result type. /// The coordinator state gate. - /// A value indicating whether a terminal notification has already been emitted. - /// A value indicating whether an inner source is active. + /// Whether a terminal notification has been emitted. + /// Whether an inner source is active. /// The coordinator to drain. internal static void OnInnerCompleted( Lock gate, diff --git a/src/Primitives.Shared/Advanced/TaskChainSignal{T}.cs b/src/Primitives.Shared/Advanced/TaskChainSignal{T}.cs index 0f4072a8..457c5511 100644 --- a/src/Primitives.Shared/Advanced/TaskChainSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/TaskChainSignal{T}.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Task-source Chain/Concat signal. +/// Emits the results of an observable sequence of tasks, one task at a time in source order. /// The task result type. [System.Diagnostics.DebuggerDisplay("TaskChainSignal: Sources = {_sources}")] public sealed class TaskChainSignal : IObservable diff --git a/src/Primitives.Shared/Advanced/TaskCountWitness{T}.cs b/src/Primitives.Shared/Advanced/TaskCountWitness{T}.cs index 1a1e2026..93ccae4d 100644 --- a/src/Primitives.Shared/Advanced/TaskCountWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/TaskCountWitness{T}.cs @@ -37,12 +37,12 @@ public sealed class TaskCountWitness : IObserver, IDisposable private int _stopped; /// Initializes a new instance of the class. - /// The token used to cancel the task and dispose the subscription. + /// The token that cancels the task and disposes the subscription. public TaskCountWitness(CancellationToken cancellationToken) => _cancellationToken = cancellationToken; /// Initializes a new instance of the class. /// The predicate. - /// The token used to cancel the task and dispose the subscription. + /// The token that cancels the task and disposes the subscription. public TaskCountWitness(Func predicate, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(predicate); diff --git a/src/Primitives.Shared/Advanced/ThrowSignal{T}.cs b/src/Primitives.Shared/Advanced/ThrowSignal{T}.cs index bec0659c..f1d49f1d 100644 --- a/src/Primitives.Shared/Advanced/ThrowSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/ThrowSignal{T}.cs @@ -10,23 +10,23 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the ThrowSignal class. -/// The T type. +/// Emits a single error on the supplied sequencer without producing any value. +/// The value type the sequence would have carried. [System.Diagnostics.DebuggerDisplay("ThrowSignal: Error = {_error}, Scheduler = {_scheduler}")] public sealed class ThrowSignal : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The error to emit. private readonly Exception _error; - /// Stores state for the signal implementation. + /// The sequencer that emits the error. private readonly ISequencer _scheduler; - /// Stores state for the signal implementation. + /// Whether subscription must be dispatched through the current-thread sequencer. private readonly bool _currentThreadRequired; /// Initializes a new instance of the class. - /// The error value. - /// The scheduler value. + /// The error to emit. + /// The sequencer that emits the error. public ThrowSignal(Exception error, ISequencer scheduler) { _error = error; @@ -34,14 +34,14 @@ public ThrowSignal(Exception error, ISequencer scheduler) _currentThreadRequired = scheduler == Sequencer.CurrentThread; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Gets whether subscription has to be dispatched through the current-thread sequencer. + /// when the supplied sequencer is the current-thread sequencer. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => _currentThreadRequired; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes an observer that receives the error. + /// The downstream observer. + /// A disposable that cancels the emission when it has not run yet. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, _currentThreadRequired, SubscribeCore); @@ -56,10 +56,10 @@ private static EmptyDisposable SignalError((IObserver Observer, Exception Err return EmptyDisposable.Instance; } - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Emits the error inline for the immediate sequencer, otherwise on the sequencer. + /// The downstream observer. + /// The subscription handle the guard checks before forwarding. + /// The disposable that cancels the scheduled emission. private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) { observer = new GuardedWitness(observer, cancel); diff --git a/src/Primitives.Shared/Advanced/TimeIntervalWitness.cs b/src/Primitives.Shared/Advanced/TimeIntervalWitness.cs index 432f64de..6dcf2c01 100644 --- a/src/Primitives.Shared/Advanced/TimeIntervalWitness.cs +++ b/src/Primitives.Shared/Advanced/TimeIntervalWitness.cs @@ -58,7 +58,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when the slot is occupied. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/Primitives.Shared/Advanced/TimerSlot.cs b/src/Primitives.Shared/Advanced/TimerSlot.cs index 8f8e7abf..c0a1a04e 100644 --- a/src/Primitives.Shared/Advanced/TimerSlot.cs +++ b/src/Primitives.Shared/Advanced/TimerSlot.cs @@ -18,11 +18,9 @@ public static class TimerSlot /// The timer callback. /// An argument is . /// - /// A sequencer may run before its own Schedule returns, and that callback - /// may arm its successor. Assigning the returned handle straight into would then - /// replace - and so cancel - the successor rather than the timer that has already fired. Reserving the - /// slot first means the late assignment lands in a reservation the successor has already superseded, and - /// the already-fired handle is disposed instead. + /// A sequencer may run before its own Schedule returns, and that callback may + /// arm its successor. Reserving first sends the late handle into a superseded + /// reservation, so the fired timer is disposed instead of the successor being cancelled. /// public static void Arm(SingleReplaceableDisposable slot, ISequencer sequencer, TimeSpan delay, Action tick) { diff --git a/src/Primitives.Shared/Advanced/UnsparkWitness.cs b/src/Primitives.Shared/Advanced/UnsparkWitness.cs index 76f5b440..64cbfa80 100644 --- a/src/Primitives.Shared/Advanced/UnsparkWitness.cs +++ b/src/Primitives.Shared/Advanced/UnsparkWitness.cs @@ -37,7 +37,7 @@ public sealed class UnsparkWitness : IObserver>, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when the slot is occupied. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/Primitives.Shared/Advanced/WitnessLifetime.cs b/src/Primitives.Shared/Advanced/WitnessLifetime.cs index 128c2836..92e6a27b 100644 --- a/src/Primitives.Shared/Advanced/WitnessLifetime.cs +++ b/src/Primitives.Shared/Advanced/WitnessLifetime.cs @@ -34,7 +34,7 @@ internal static void Dispose(ref IDisposable? cancelSlot, ref int stopped) /// when the sink has stopped. internal static bool IsStopped(ref int stopped) => Volatile.Read(ref stopped) != 0; - /// Assigns the upstream subscription and disposes it when the sink has already stopped. + /// Assigns the upstream subscription, disposing it when the sink has stopped. /// The stopped flag. /// The upstream subscription slot. /// The upstream subscription. @@ -89,7 +89,7 @@ internal static void Error( observer.OnError(terminalError); } - /// Assigns the cancellation resource or disposes it when the witness already stopped. + /// Assigns the cancellation resource, disposing it when the witness has stopped. /// The slot that owns the cancellation resource. /// The stopped flag. /// The cancellation resource to assign. @@ -111,7 +111,7 @@ internal static void SetCancel(ref IDisposable? cancelSlot, ref int stopped, IDi Interlocked.Exchange(ref cancelSlot, null)?.Dispose(); } - /// Forwards a value when the witness is still active. + /// Forwards a value while the witness is active. /// The witness owner type. /// The value type. /// The stopped flag. diff --git a/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs b/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs index 82fb2652..eea0ab6e 100644 --- a/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs @@ -10,63 +10,60 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Represents the WitnessOnSignal class. -/// The T type. -/// The source value. -/// The scheduler value. +/// Re-dispatches source notifications onto a sequencer, preserving their order. +/// The value type. +/// The source observable. +/// The sequencer that dispatches the notifications. internal sealed class WitnessOnSignal(IObservable source, ISequencer scheduler) : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The source observable. private readonly IObservable _source = source; - /// Stores state for the signal implementation. + /// The sequencer that dispatches the notifications. private readonly ISequencer _scheduler = scheduler; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Reports that subscription is dispatched through the current-thread sequencer. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => true; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Subscribes an observer through the current-thread sequencer. + /// The downstream observer. + /// The subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => SignalSubscription.Subscribe(observer, true, SubscribeCore); - /// Executes the SubscribeCore operation. - /// The observer value. - /// The cancel value. - /// The result. + /// Builds the dispatching sink and subscribes it to the source. + /// The downstream observer. + /// The subscription handle owned by the subscription helper. + /// The source subscription together with the sink. [MethodImpl(MethodImplOptions.AggressiveInlining)] private IDisposable SubscribeCore(IObserver observer, IDisposable cancel) => new WitnessOn(this, observer, cancel).Run(); - /// Represents the WitnessOn class. - /// The parent value. - /// The observer value. - /// The cancel value. + /// Queues source notifications and drains them on the sequencer. + /// The owning signal. + /// The downstream observer. + /// The subscription handle released on teardown. private sealed class WitnessOn(WitnessOnSignal parent, IObserver observer, IDisposable cancel) : IObserver, IWorkItem, IsDisposed { - /// Stores state for the signal implementation. + /// The owning signal. private readonly WitnessOnSignal _parent = parent; - /// Stores the downstream observer. + /// The downstream observer. private readonly IObserver _observer = observer; /// Synchronization gate guarding the queued actions and scheduling state. private readonly Lock _gate = new(); - /// - /// Queued notifications awaiting dispatch on the scheduler. Stored as a value type so - /// queueing a notification does not allocate a per OnNext. - /// + /// Notifications awaiting dispatch on the sequencer. private readonly Queue _actions = new(); /// Upstream subscription disposed on teardown. private IDisposable? _cancel = cancel; - /// Stores state for the signal implementation. + /// Whether the sink has been torn down. private bool _isDisposed; /// Tracks whether a drain has been scheduled. @@ -84,8 +81,8 @@ public bool IsDisposed } } - /// Executes the Run operation. - /// The result. + /// Subscribes to the source. + /// The source subscription together with this sink. public MultipleDisposable Run() { _isDisposed = false; @@ -95,17 +92,17 @@ public MultipleDisposable Run() return new(sourceDisposable, this); } - /// Executes the OnNext operation. - /// The value. + /// Queues a value for dispatch. + /// The value to queue. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(T value) => QueueAction(Notification.OnNext(value)); - /// Executes the OnError operation. - /// The error value. + /// Queues an error for dispatch. + /// The error to queue. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnError(Exception error) => QueueAction(Notification.OnError(error)); - /// Executes the OnCompleted operation. + /// Queues completion for dispatch. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => QueueAction(Notification.OnCompleted()); @@ -143,7 +140,7 @@ public void Execute() } } - /// Executes the Dispose operation. + /// Clears the queue and releases the upstream subscription, once. public void Dispose() { IDisposable? cancel; @@ -163,8 +160,8 @@ public void Dispose() cancel?.Dispose(); } - /// Executes the QueueAction operation. - /// The data value. + /// Queues a notification and schedules a drain when one is not pending. + /// The notification to queue. private void QueueAction(in Notification data) { lock (_gate) @@ -186,8 +183,8 @@ private void QueueAction(in Notification data) _parent._scheduler.Schedule(this); } - /// Executes the Dispatch operation. - /// The action value. + /// Forwards one notification to the downstream observer. + /// The notification to forward. private void Dispatch(in Notification action) { switch (action.Kind) diff --git a/src/Primitives.Shared/Concurrency/SequencerWorkItem.cs b/src/Primitives.Shared/Concurrency/SequencerWorkItem.cs index 0ca3d771..f0764be0 100644 --- a/src/Primitives.Shared/Concurrency/SequencerWorkItem.cs +++ b/src/Primitives.Shared/Concurrency/SequencerWorkItem.cs @@ -29,7 +29,7 @@ internal sealed class SequencerWorkItem(TSequencer sequencer /// Disposable returned by the scheduled action after it starts. private IDisposable? _disposable; - /// Tracks cancellation. + /// Non-zero once the work item has been cancelled. private int _isDisposed; /// Cancels the work item. diff --git a/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs b/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs index 4ba9d210..b7a8c76f 100644 --- a/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs +++ b/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs @@ -15,10 +15,8 @@ internal static class SequencerWorkItemDisposal /// The disposable slot shared with the work item's disposal. /// The disposable returned by the scheduled action. /// - /// Disposal swaps a non-null sentinel into the slot, so a non-null exchange result means disposal already - /// owns (or will own) the slot and the freshly produced disposable must be released here. A null result - /// means this caller published first and disposal releases the slot later. This keeps the in-flight dispose - /// race correct with a single compare-exchange instead of a re-check loop. + /// Disposal swaps a non-null sentinel into the slot, so a non-null exchange result means disposal owns the + /// slot and this caller must release the disposable it produced. /// internal static void Publish(ref IDisposable? slot, IDisposable disposable) { diff --git a/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs b/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs index e9a6065e..2454e531 100644 --- a/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs +++ b/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs @@ -10,11 +10,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// The System.Reactive names for the hot-sharing operators, so code written against Rx reads unchanged here. -/// Each one builds its hub directly rather than forwarding to its Primitives-named twin in -/// , so an alias costs nothing at the call site. -/// +/// System.Reactive names for the hot-sharing operators, so code written against Rx compiles unchanged. public static class ConnectableSignalRxNameExtensions { /// Reference-counting operators for a connectable signal source. @@ -51,7 +47,7 @@ public ConnectableSignal Publish() [SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = "Share and Publish are the same operator under two names callers expect; forwarding one to the other would only add a hop.")] + Justification = "Share and Publish are the same operator under two names callers expect.")] public ConnectableSignal Share() { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/Primitives.Shared/Core/Spark.cs b/src/Primitives.Shared/Core/Spark.cs index 9551e62e..d5b49df2 100644 --- a/src/Primitives.Shared/Core/Spark.cs +++ b/src/Primitives.Shared/Core/Spark.cs @@ -34,8 +34,7 @@ public static class Spark [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static Spark CreateOnError(Exception error) { ArgumentExceptionHelper.ThrowIfNull(error); @@ -53,7 +52,6 @@ public static Spark CreateOnError(Exception error) [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static Spark CreateOnCompleted() => Spark.OnCompleted(); } diff --git a/src/Primitives.Shared/Core/SparkCompositeFormats.cs b/src/Primitives.Shared/Core/SparkCompositeFormats.cs index 744088cd..bb1b5b6a 100644 --- a/src/Primitives.Shared/Core/SparkCompositeFormats.cs +++ b/src/Primitives.Shared/Core/SparkCompositeFormats.cs @@ -12,9 +12,8 @@ namespace ReactiveUI.Primitives.Core; /// Holds the parsed composite formats renders with. /// -/// The formats sit on a non-generic type on purpose. A static field inside is -/// a field of each closed generic, so the format would be parsed once per T; one shared instance -/// serves every one of them. +/// A static field in is a distinct field per closed generic, so the formats live on a +/// non-generic type to be parsed once for every T. /// internal static class SparkCompositeFormats { diff --git a/src/Primitives.Shared/Core/Spark{T}.cs b/src/Primitives.Shared/Core/Spark{T}.cs index a63b8be7..366358bc 100644 --- a/src/Primitives.Shared/Core/Spark{T}.cs +++ b/src/Primitives.Shared/Core/Spark{T}.cs @@ -13,9 +13,8 @@ namespace ReactiveUI.Primitives.Core; #endif /// -/// Represents a spark to an observer. This is a by-value type: materializing a sequence allocates -/// no per-notification heap object, mirroring the value-type notification used by other modern -/// reactive libraries. +/// Represents a spark to an observer. Being a value type, materializing a sequence allocates no +/// per-notification heap object. /// /// The type of the elements received by the observer. [Serializable] @@ -46,7 +45,7 @@ private Spark(SparkKind kind, T value, Exception? exception) /// Gets a value indicating whether the spark carries a value. public bool HasValue => Kind == SparkKind.OnNext; - /// Gets the exception of an OnError spark or returns null. + /// Gets the exception carried by an OnError spark, or for any other kind. public Exception Exception => _exception!; /// Gets the kind of Spark that is represented. @@ -57,13 +56,12 @@ private Spark(SparkKind kind, T value, Exception? exception) [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Determines whether the current Spark<T> object has the same observer message payload as a specified Spark<T> value. - /// An object to compare to the current Spark<T> object. - /// true if both Spark<T> objects have the same observer message payload; otherwise, false. - /// - /// Equality of Spark<T> objects is based on the equality of the observer message payload they represent, - /// including the Spark Kind and the Value or Exception (if any). - /// + /// + /// Determines whether both sparks carry the same observer message payload: the same and, + /// for OnNext and OnError, the same value or exception. + /// + /// The spark to compare with this one. + /// true if both sparks carry the same payload; otherwise, false. public bool Equals(Spark other) => Kind == other.Kind && Kind switch { SparkKind.OnNext => EqualityComparer.Default.Equals(Value, other.Value), diff --git a/src/Primitives.Shared/Handle.cs b/src/Primitives.Shared/Handle.cs index dc2d8e41..a3c22c7a 100644 --- a/src/Primitives.Shared/Handle.cs +++ b/src/Primitives.Shared/Handle.cs @@ -27,8 +27,7 @@ internal static class Handle [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "No argument carries the element type, so it can only come from an explicit type argument.")] internal static IObservable CatchIgnore(Exception ex) => Signal.None(); } diff --git a/src/Primitives.Shared/LinqExtensions.cs b/src/Primitives.Shared/LinqExtensions.cs index cfc1b884..469c1e9e 100644 --- a/src/Primitives.Shared/LinqExtensions.cs +++ b/src/Primitives.Shared/LinqExtensions.cs @@ -15,14 +15,14 @@ public static partial class LinqExtensions /// The disposable. extension(IDisposable disposable) { - /// Disposes the with. - /// A SingleDisposable. + /// Wraps the disposable so that disposing the wrapper disposes it exactly once. + /// A that owns this disposable. public SingleDisposable DisposeWith() => new(disposable); - /// Disposes the with. - /// The action. - /// A SingleDisposable. + /// Wraps the disposable and runs an action just before it is disposed. + /// The action to run on disposal, or to run nothing extra. + /// A that owns this disposable and first invokes . public SingleDisposable DisposeWith(Action? action) => new(disposable, action); } @@ -32,11 +32,11 @@ public SingleDisposable DisposeWith(Action? action) => /// The source. extension(IObservable source) { - /// Buffers the specified count. - /// The count of each buffer. - /// An Signals sequence of buffers. - /// source. - /// count. + /// Groups the source values into consecutive, non-overlapping buffers of a fixed size. + /// The number of values in each buffer. + /// An observable sequence of buffers; the final buffer is shorter when the source ends mid-window. + /// is . + /// is zero or negative. public IObservable> Buffer(int count) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -46,16 +46,12 @@ public IObservable> Buffer(int count) return new BufferCountSignal(source, count, 0); } - /// Buffers the specified count then skips the specified count, then repeats. - /// Length of each buffer before being skipped. - /// Number of elements to skip between creation of consecutive buffers. - /// An Signals sequence of buffers taking the count then skipping the skipped value, the sequecnce is then repeated. - /// source. - /// - /// count - /// or - /// skip. - /// + /// Groups values into fixed-size buffers, opening a new buffer every values. + /// The number of values in each buffer. + /// The number of values between the starts of consecutive buffers; a value below makes buffers overlap. + /// An observable sequence of buffers, each opened values after the one before it. + /// is . + /// or is zero or negative. public IObservable> Buffer(int count, int skip) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -74,9 +70,9 @@ public IObservable> Buffer(int count, int skip) extension(T disposable) where T : IDisposable { - /// Disposes the IDisposable with the disposables instance. - /// The disposables. - /// The original disposable. + /// Adds the disposable to a composite that will dispose it. + /// The composite taking ownership of the disposable. + /// The same disposable, so the call can be chained onto its creation. public T DisposeWith(MultipleDisposable disposables) { ArgumentExceptionHelper.ThrowIfNull(disposables); diff --git a/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs b/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs index ba566d8c..6cb93de3 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs @@ -12,14 +12,13 @@ namespace ReactiveUI.Primitives; /// /// Fused Blend + Unique operator: concurrently merges a fixed set of sources and forwards a value -/// only when it differs from the previously forwarded one. Folding the merge and distinct-until-changed into a -/// single sink avoids the extra subscription hop and allocation of sources.Blend().Unique(). +/// only when it differs from the last forwarded one, through one sink instead of two. /// public static partial class LinqExtensions { /// - /// Concurrently merges the supplied sources and forwards only values that differ from the previously - /// forwarded value, using the default equality comparer. Errors are forwarded from the first failing source; + /// Concurrently merges the supplied sources and forwards only values that differ from the last forwarded + /// value, using the default equality comparer. Errors are forwarded from the first failing source; /// completion is signalled once every source has completed. /// /// The element type. @@ -30,8 +29,8 @@ public static IObservable BlendUnique(params IObservable[] sources) => BlendUnique(sources, null); /// - /// Concurrently merges the supplied sources and forwards only values that differ from the previously - /// forwarded value, using the supplied comparer (or the default when ). + /// Concurrently merges the supplied sources and forwards only values that differ from the last forwarded + /// value, using the supplied comparer (or the default when ). /// /// The element type. /// The sources to merge. @@ -134,7 +133,7 @@ public void Run(IObservable[] sources) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => _pocket.Dispose(); - /// Forwards a value when it differs from the previously forwarded one. + /// Forwards a value when it differs from the last forwarded one. /// The merged value. private void Forward(T value) { diff --git a/src/Primitives.Shared/SignalOperatorMixins.BufferSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.BufferSignals.cs index 21c35e74..b8ff0be4 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.BufferSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.BufferSignals.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// Dedicated cold signal/sink for Buffer. Each subscription gets its own lightweight sink -/// (no broadcaster, no eager subscription); windows are buffered into a single array of the known -/// window size and emitted directly, with only a partial trailing window copied to an exact size. -/// +/// Implementation types for the count-based buffering overloads. public static partial class LinqExtensions { /// Dedicated cold signal for Buffer. diff --git a/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs b/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs index 2be7bd1c..61df4a34 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// Fused projection operators: Choose (filter + map in one sink), SwitchMap (map-to-inner + -/// switch-to-latest in one sink) and SwitchSelect (the same, skipping null source values). -/// +/// Fused projection operators that combine filtering or switching with a projection in one sink. public static partial class LinqExtensions { /// Fused projection operators for an observable source sequence. diff --git a/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs index 9f9fd21d..857f3512 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// Dedicated signals/sinks for the terminal collection operators (CollectList, CollectArray) and -/// their eager range-backed fast paths, replacing the per-subscription -/// Signal.CreateSafe(observer => ...) closures with parameter-holding signals. -/// +/// Signals backing the terminal collection operators and their eager range fast paths. public static partial class LinqExtensions { /// Dedicated signal for CollectList. @@ -61,10 +57,6 @@ public IDisposable Subscribe(IObserver observer) /// Eager range-backed signal for CollectList (no per-value subscription). /// The result element type, which is always . - /// is sealed and implements only of - /// . Covariance does not apply to a value-type argument, so an - /// can only hold a range when is exactly - /// — which is what every construction site asserts. private sealed class RangeListSignal : IObservable> { /// The source range. @@ -85,6 +77,7 @@ public IDisposable Subscribe(IObserver> observer) values.Add(_range.Start + i); } + // A value-type argument is not covariant, so this cast holds only because every call site fixes T to int. observer.OnNext((IList)(object)values); observer.OnCompleted(); return EmptyDisposable.Instance; @@ -93,10 +86,6 @@ public IDisposable Subscribe(IObserver> observer) /// Eager range-backed signal for CollectArray (no per-value subscription). /// The result element type, which is always . - /// is sealed and implements only of - /// . Covariance does not apply to a value-type argument, so an - /// can only hold a range when is exactly - /// — which is what every construction site asserts. private sealed class RangeArraySignal : IObservable { /// The source range. @@ -117,6 +106,7 @@ public IDisposable Subscribe(IObserver observer) values[i] = _range.Start + i; } + // A value-type argument is not covariant, so this cast holds only because every call site fixes T to int. observer.OnNext((T[])(object)values); observer.OnCompleted(); return EmptyDisposable.Instance; diff --git a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.WideArity.cs b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.WideArity.cs index c464a254..79ff4dd2 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.WideArity.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.WideArity.cs @@ -44,7 +44,7 @@ private sealed partial class CombineLatestSignal [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] internal static CombineLatestSignal Create( IObservable source, IObservable source2, @@ -112,7 +112,7 @@ internal static CombineLatestSignal Create Create( IObservable source, IObservable source2, @@ -185,7 +185,7 @@ internal static CombineLatestSignal Create Create( IObservable source, IObservable source2, @@ -263,7 +263,7 @@ internal static CombineLatestSignal Create Create( IObservable source, IObservable source2, @@ -346,7 +346,7 @@ internal static CombineLatestSignal Create Create( IObservable source, @@ -435,7 +435,7 @@ internal static CombineLatestSignal [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] internal static CombineLatestSignal Create( IObservable source, IObservable source2, @@ -528,7 +528,7 @@ internal static CombineLatestSignal Create Create( IObservable source, IObservable source2, diff --git a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs index 3f85fe5c..af954501 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs @@ -25,8 +25,7 @@ private abstract class CombineLatestSlot /// Observable implementation for generated multi-source combine-latest overloads. /// The projected result type. /// - /// Creates one typed slot per source against a fresh coordinator and returns the projection that reads - /// them. Running per subscription is what keeps every latest value in a field of its own source's type. + /// Creates one typed slot per source against a fresh coordinator and returns the projection that reads them. /// private sealed partial class CombineLatestSignal( Func, Func> connect) : IObservable @@ -202,7 +201,7 @@ internal static CombineLatestSignal Create( [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] internal static CombineLatestSignal Create( IObservable source, IObservable source2, @@ -255,7 +254,7 @@ internal static CombineLatestSignal Create( [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] internal static CombineLatestSignal Create( IObservable source, IObservable source2, @@ -313,7 +312,7 @@ internal static CombineLatestSignal Create Create( IObservable source, IObservable source2, @@ -350,10 +349,7 @@ internal static CombineLatestSignal Create - /// Holds the latest value of one source in a field of that source's own type, and observes the source - /// directly so a subscription costs one object per source rather than a closure and a delegate per callback. - /// + /// Holds the latest value of one source in a field of that source's own type and observes it directly. /// The projected result type. /// The source element type. /// The coordinator that serializes this slot against its siblings. @@ -411,21 +407,20 @@ private sealed class CombineLatestCoordinator : IDisposable /// /// One flag per source twice over: the first half records whether a source has produced a value, the - /// second whether it has completed. A single array keeps both counters' state in one allocation and - /// stays correct for the collection overloads, whose source count has no upper bound. + /// second whether it has completed. /// private bool[] _flags = []; /// The projection over this subscription's slots. private Func _project = null!; - /// The number of sources still waiting for their first value. + /// The number of sources yet to produce their first value. private int _missingValues; /// The number of sources that have not completed. private int _remainingCompletions; - /// Whether a terminal notification has already been forwarded. + /// Whether a terminal notification has been forwarded. private bool _completed; /// Initializes a new instance of the class. @@ -450,12 +445,9 @@ internal CombineLatestSlot Attach(IObservable source) /// Subscribes to every attached source and returns this coordinator as the subscription. /// The projection over the slots created by . /// This coordinator. - /// - /// The projection is installed before the first subscription, so a source that produces a value inside - /// its own subscribe call still finds somewhere to project into. - /// internal CombineLatestCoordinator Run(Func project) { + // Installed before the first subscribe, so a source that emits inside its own Subscribe has a target. _project = project; _flags = new bool[_slots.Count * FlagsPerSource]; _missingValues = _slots.Count; diff --git a/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs b/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs index 8b707e3f..c1a27d67 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs @@ -14,9 +14,7 @@ namespace ReactiveUI.Primitives; public static partial class LinqExtensions { /// - /// Range-specialized WithLatest (Latch): emits each left range value paired with the right - /// range's final value. A dedicated signal avoids the closure, delegate, CreateSafe wrapper, - /// and safe-guard sink that Signal.CreateSafe(observer => ...) would allocate. + /// Range-specialized WithLatest (Latch): emits each left range value paired with the right range's final value. /// /// The result value type. /// The left source range. @@ -49,7 +47,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Dedicated signal for Race; runs the coordinator without a Create closure. + /// Dedicated signal for Race, handing each subscription to a coordinator. /// The value type. private sealed class RaceSignal : IObservable { @@ -69,7 +67,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Dedicated signal for Zip; runs the coordinator without a Create closure. + /// Dedicated signal for Zip, holding the two sources and the projection. /// The left value type. /// The right value type. /// The result value type. @@ -96,7 +94,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Dedicated signal for CombineLatest; runs the coordinator without a Create closure. + /// Dedicated signal for the two-source CombineLatest path. /// The left value type. /// The right value type. /// The result value type. @@ -484,9 +482,8 @@ private void OnRightCompleted() } /// - /// Emits all currently available pairs. The gate is held across the projection and the - /// downstream callbacks so left and right threads cannot interleave emissions (the Rx - /// serialization contract) and completion is delivered at most once. + /// Emits every pair the queues can form. The gate is held across the projection and the downstream + /// callbacks, so the left and right threads cannot interleave emissions. /// private void Drain() { @@ -562,11 +559,7 @@ internal MultipleDisposable Run(IObservable left, IObservable rig left.Subscribe(OnLeftNext, _observer.OnError, OnLeftCompleted), right.Subscribe(OnRightNext, _observer.OnError, OnRightCompleted)); - /// - /// Handles a left value. The gate is held across the projection and the downstream - /// callback so left and right threads cannot interleave emissions (the Rx serialization - /// contract). - /// + /// Handles a left value, holding the gate across the projection so emissions cannot interleave. /// The left value. private void OnLeftNext(TLeft value) { diff --git a/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs index 6a86bbd3..05594380 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs @@ -11,10 +11,8 @@ namespace ReactiveUI.Primitives; #endif /// -/// Dedicated signals for the scheduler/time operators, replacing the per-subscription -/// Signal.Create(observer => ...) closures. The current-thread variants follow the -/// ExpireSignal/ProbeSignal pattern: implement -/// and schedule the subscription onto the current-thread sequencer when required. +/// Signals for the scheduler and time operators. The current-thread variants implement +/// and schedule their subscription onto the current-thread sequencer. /// public static partial class LinqExtensions { @@ -153,7 +151,7 @@ private sealed class ShiftCoordinator(IObservable source, TimeSpan dueTime /// A value indicating whether a timer or drain is active. private bool _timerActive; - /// A value indicating whether the source has already signaled terminal notification. + /// A value indicating whether the source has signaled a terminal notification. private bool _sourceStopped; /// A value indicating whether a terminal notification has been delivered. @@ -348,7 +346,7 @@ private bool Deliver(DelayedNotification notification) /// A delayed source notification. private sealed class DelayedNotification { - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// The notification kind. /// The notification value. /// The notification error. diff --git a/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs index 8a2041a1..8f5b7a35 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs @@ -10,12 +10,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// Dedicated implementations for the stateful single-source operators. -/// Building through these instead of Signal.CreateSafe(observer => ...) removes the -/// per-subscription closure, delegate, CreateSafe wrapper, and safe-guard sink, leaving only the -/// signal object at chain-build time and the operator sink at subscribe time. -/// +/// implementations for the stateful single-source operators. public static partial class LinqExtensions { /// Dedicated signal for Take. @@ -41,13 +36,9 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // A current-thread source runs its work on the trampoline of whichever call enters it first. - // If that call is the source's own Subscribe, the source drains the trampoline before this - // operator can hand the upstream subscription to its sink, so an endless source (an interval - // timer, a repeating loop) never learns that the count was reached and the drain never ends. - // Entering the trampoline here instead means the source's Subscribe only queues its work and - // returns, the sink owns the upstream subscription before the first value is delivered, and - // reaching the count disposes the source and empties the queue. + // A current-thread source drains the trampoline inside whichever call enters it first. When that call + // is the source's own Subscribe, the sink does not hold the upstream subscription yet, so an endless + // source never learns the count was reached. Entering the trampoline here leaves Subscribe queueing only. if (!CurrentThreadRequirement.IsRequired(_source) || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); @@ -75,7 +66,7 @@ private TakeWitness SubscribeCore(IObserver observer) } } - /// Dedicated signal for TakeUntil that holds its sources without a per-subscription closure. + /// Dedicated signal for TakeUntil, holding both the source and the stop arm. /// The source value type. /// The cancellation value type. /// The source observable. @@ -93,11 +84,9 @@ public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - // Either arm can be a current-thread source, and whichever this operator subscribes first would run its - // work on the trampoline of that very call — draining it before the coordinator has been handed the - // subscription it needs in order to stop. An endless source therefore never learns the stop arm fired. - // Entering the trampoline here instead means both arms only queue their work and return, the coordinator - // owns both subscriptions before the first notification is delivered, and stopping disposes them. + // Either arm can be a current-thread source, and whichever is subscribed first drains the trampoline + // inside that call, before the coordinator holds the subscription it needs in order to stop. Entering + // the trampoline here leaves both arms queueing only, so the coordinator owns both subscriptions first. if ((!CurrentThreadRequirement.IsRequired(_source) && !CurrentThreadRequirement.IsRequired(_other)) || !CurrentThreadSequencer.IsScheduleRequired) { @@ -115,7 +104,7 @@ public IDisposable Subscribe(IObserver observer) return subscription; } - /// Subscribes the stop arm and, unless it has already fired, the source. + /// Subscribes the stop arm and, unless it has fired, the source. /// The downstream observer. /// The coordinator that owns both subscriptions. private TakeUntilCoordinator SubscribeCore(IObserver observer) @@ -325,7 +314,7 @@ public IDisposable Subscribe(IObserver observer) } /// Creates the duplicate-tracking set, pre-sized when the source has a known element count. - /// The set used to track already-observed values. + /// The set that tracks the values seen so far. private HashSet CreateSeen() => #if NET8_0_OR_GREATER (_source is RangeSignal range ? range.Count : 0) switch @@ -469,10 +458,9 @@ public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - // A current-thread source drains its trampoline inside its own Subscribe, so the sink would not own the - // upstream subscription until that drain ended — and on an endless source it never does, because the sink - // cannot dispose a subscription it has not been handed. Entering the trampoline here first means the - // source only queues its work, and the failing predicate can stop it. + // A current-thread source drains its trampoline inside its own Subscribe, so on an endless source the sink + // never receives the upstream subscription it would have to dispose. Entering the trampoline here leaves + // the source queueing only, so a failing predicate can stop it. if (!CurrentThreadRequirement.IsRequired(_source) || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/SignalOperatorMixins.Switch.cs b/src/Primitives.Shared/SignalOperatorMixins.Switch.cs index cc541eb2..6457b889 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.Switch.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.Switch.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives; /// The Switch operator: subscribes to the most recent inner sequence and drops the previous one. public static partial class LinqExtensions { - /// Dedicated signal for SwitchTo; runs the coordinator without a Create closure. + /// Dedicated signal for SwitchTo that hands each subscription to a coordinator. /// The value type. private sealed class SwitchSignal : IObservable { diff --git a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs index e4bb79a2..7d486880 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs @@ -238,7 +238,7 @@ public IObservable SyncLatest( [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] public IObservable SyncLatest( IObservable source2, IObservable source3, @@ -298,7 +298,7 @@ public IObservable SyncLatest( [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] public IObservable SyncLatest( IObservable source2, IObservable source3, diff --git a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs index b33cc90b..c5ff8289 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs @@ -47,7 +47,7 @@ public static partial class LinqExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] public IObservable SyncLatest( IObservable source2, IObservable source3, @@ -117,7 +117,7 @@ public IObservable SyncLatest SyncLatest( IObservable source2, IObservable source3, @@ -192,7 +192,7 @@ public IObservable SyncLatest SyncLatest( IObservable source2, IObservable source3, @@ -272,7 +272,7 @@ public IObservable SyncLatest SyncLatest( IObservable source2, IObservable source3, @@ -357,7 +357,7 @@ public IObservable SyncLatest SyncLatest( IObservable source2, IObservable source3, @@ -447,7 +447,7 @@ public IObservable SyncLatest SyncLatest( IObservable source2, IObservable source3, @@ -542,7 +542,7 @@ public IObservable SyncLatest SyncLatest( IObservable source2, IObservable source3, diff --git a/src/Primitives.Shared/SignalOperatorMixins.cs b/src/Primitives.Shared/SignalOperatorMixins.cs index 3bc5a7a1..eafa2030 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.cs @@ -308,7 +308,7 @@ public IObservable Skip(int count) return new SkipSignal(source, count); } - /// Suppresses values that have already been observed. + /// Suppresses every value that matches one seen earlier in the sequence. /// A sequence containing the first occurrence of each source value. /// is . public IObservable Distinct() @@ -318,7 +318,7 @@ public IObservable Distinct() return new DistinctSignal(source, null); } - /// Suppresses values that have already been observed using the supplied comparer. + /// Suppresses every value that the supplied comparer matches to one seen earlier. /// The comparer used to identify duplicate values. /// A sequence containing the first occurrence of each source value. /// is . @@ -634,8 +634,7 @@ public IObservable Chain() [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "No argument carries the result type, so it can only come from an explicit type argument.")] public IObservable KeepType() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -650,8 +649,7 @@ public IObservable KeepType() [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "No argument carries the result type, so it can only come from an explicit type argument.")] public IObservable CastTo() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -671,8 +669,7 @@ public IObservable CastTo() [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = - "Synchronous read of an already-completed (RanToCompletion) task preserves the existing allocation-free fast path; await is invalid in this synchronous factory.")] + Justification = "The read is guarded by a RanToCompletion check, so the task is done.")] public IObservable ToSignal() { ArgumentExceptionHelper.ThrowIfNull(task); diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs index 09c0e5c5..a7b60465 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs @@ -25,7 +25,7 @@ private interface ICountSource IDisposable SubscribeLongCount(IObserver observer); } - /// Distinct-by operator implemented without delegate observer wrappers. + /// Forwards the first source value for each distinct key. /// The source value type. /// The key type. /// The source observable. @@ -115,7 +115,7 @@ private static int CountDistinctRange( } } - /// Count operator implemented without fold composition. + /// Emits the number of source values once the source completes. /// The source value type. private sealed class CountSignal : IRequireCurrentThread { @@ -153,7 +153,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Predicate count operator implemented without fold composition. + /// Emits the number of source values that match a predicate. /// The source value type. /// The source observable. /// The predicate. @@ -213,7 +213,7 @@ private static void EmitCountRange(RangeSignal range, Func predicate, I } } - /// Long-count operator implemented without fold composition. + /// Emits the number of source values as a 64-bit count. /// The source value type. private sealed class LongCountSignal : IRequireCurrentThread { @@ -251,7 +251,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Predicate long-count operator implemented without fold composition. + /// Emits the number of matching source values as a 64-bit count. /// The source value type. /// The source observable. /// The predicate. @@ -311,7 +311,7 @@ private static void EmitLongCountRange(RangeSignal range, Func predicat } } - /// Any operator implemented without predicate composition. + /// Emits whether the source produced any value. /// The source value type. private sealed class AnySignal : IRequireCurrentThread { @@ -368,7 +368,7 @@ private AnyWitness SubscribeCore(IObserver observer) } } - /// Predicate any operator implemented without delegate observer wrappers. + /// Emits whether any source value matches a predicate. /// The source value type. /// The source observable. /// The predicate. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs b/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs index a7cde36d..24910d2c 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs @@ -66,8 +66,8 @@ public Task FirstOrDefaultAsync(T defaultValue) /// The token used to cancel the task and dispose the subscription. /// A task that completes with the first source value, or when the source is empty. /// The receiver sequence is . - /// Deprioritized so calls like FirstOrDefaultAsync(default!) keep binding to the - /// FirstOrDefaultAsync(T) overload they compiled against before this overload existed. + /// Deprioritized, so a call like FirstOrDefaultAsync(default!) binds to the + /// FirstOrDefaultAsync(T) overload rather than this one. [OverloadResolutionPriority(-1)] public Task FirstOrDefaultAsync(CancellationToken cancellationToken) { @@ -141,8 +141,8 @@ public Task LastOrDefaultAsync(T defaultValue) /// The token used to cancel the task and dispose the subscription. /// A task that completes with the final source value, or when the source is empty. /// The receiver sequence is . - /// Deprioritized so calls like LastOrDefaultAsync(default!) keep binding to the - /// LastOrDefaultAsync(T) overload they compiled against before this overload existed. + /// Deprioritized, so a call like LastOrDefaultAsync(default!) binds to the + /// LastOrDefaultAsync(T) overload rather than this one. [MethodImpl(MethodImplOptions.AggressiveInlining)] [OverloadResolutionPriority(-1)] public Task LastOrDefaultAsync(CancellationToken cancellationToken) => diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs index da97c8ec..0e786046 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives; /// Private helper types for boolean terminal parity operators. public static partial class LinqExtensions { - /// Predicate all operator implemented without delegate observer wrappers. + /// Emits whether every source value matches a predicate. /// The source value type. /// The source observable. /// The predicate. @@ -98,7 +98,7 @@ private AllPredicateWitness SubscribeCore(IObserver observer) } } - /// Contains operator implemented without composing Any and comparer closures. + /// Emits whether the source produced a value equal to the one sought. /// The source value type. /// The source observable. /// The value to locate. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs index 1874be83..39229e3e 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs @@ -14,7 +14,7 @@ namespace ReactiveUI.Primitives; /// FlatMap helper implementations. public static partial class LinqExtensions { - /// Chaining FlatMap signal that avoids the Map + Chain composition path. + /// FlatMap signal that subscribes the projected inner observables in source order. /// The source value type. /// The result value type. /// The source observable. @@ -119,7 +119,7 @@ private sealed class FlatMapCoordinator : IDisposable /// Value indicating whether the active inner source is currently subscribing. private bool _subscribingInner; - /// Value indicating whether the active inner source completed while its subscribe call was still on the stack. + /// Value indicating whether the active inner source completed during its own subscribe call. private bool _completedInnerWhileSubscribing; /// Initializes a new instance of the class. @@ -129,8 +129,7 @@ private sealed class FlatMapCoordinator : IDisposable [SuppressMessage( "Correctness", "SST2403:Do not let 'this' escape from a constructor", - Justification = - "The witnesses are this coordinator's own sinks, stored back into its fields, and nothing notifies them until Run subscribes.")] + Justification = "The witnesses are this coordinator's own fields and nothing notifies them until Run subscribes.")] internal FlatMapCoordinator( IObservable source, Func> selector, @@ -229,9 +228,9 @@ private void OnOuterCompleted() /// The inner value. private void OnInnerNext(TResult value) { - // Hot path: only one inner is active at a time (sequential concat semantics), so the - // forward is already serialized. A lock-free volatile read of the disposed flag avoids - // a monitor acquire on every value; the lock releases elsewhere publish the write. + // Hot path: only one inner is active at a time (sequential concat semantics), so this forward is + // serialized without the gate. A volatile read of the disposed flag avoids a monitor acquire on + // every value; the lock releases elsewhere publish the write. if (Volatile.Read(ref _disposed)) { return; diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.ForkJoin.cs b/src/Primitives.Shared/SignalOperatorParityMixins.ForkJoin.cs index 85b07d1b..a77f4603 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.ForkJoin.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.ForkJoin.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives; /// The ForkJoin operator: pairs the final value of each source once both have completed. public static partial class LinqExtensions { - /// Dedicated signal for ForkJoin; runs the coordinator without a Create closure. + /// Dedicated signal for ForkJoin that pairs each source's final value. /// The left value type. /// The right value type. /// The result value type. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs index bd7a44ee..085202b6 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs @@ -46,7 +46,7 @@ private static EmptyDisposable EmitShiftedRange(Action onNext, Action onCo return EmptyDisposable.Instance; } - /// Prepends a single value without composing through concat and return signals. + /// Emits one value, then the source sequence. /// The source value type. /// The source observable. /// The prepended value. @@ -85,7 +85,7 @@ public IDisposable Subscribe(Action onNext, Action onError, Action internal T GetValue() => _value; } - /// Prepends an enumerable without composing through concat and enumerable signals. + /// Emits the values of an enumerable, then the source sequence. /// The source value type. /// The source observable. /// Values emitted before source subscription. @@ -205,7 +205,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Range timestamp projection with no intermediate map observer. + /// Projects a range's values into timestamped moments. /// The range value type. /// The range source. /// The sequencer used to read timestamps. @@ -258,7 +258,7 @@ private void Emit(Action> onNext) } } - /// Emits timestamped range values to an observer without allocating a delegate wrapper. + /// Emits timestamped range values straight to an observer. /// The downstream observer. private void Emit(IObserver> observer) { @@ -280,7 +280,7 @@ private void Emit(IObserver> observer) } } - /// Range time-interval projection with no intermediate safe signal closure. + /// Projects a range's values into interval-tagged values. /// The range value type. /// The range source. /// The sequencer used to read timestamps. @@ -336,7 +336,7 @@ private void Emit(Action> onNext) } } - /// Emits interval-tagged range values to an observer without allocating a delegate wrapper. + /// Emits interval-tagged range values straight to an observer. /// The downstream observer. private void Emit(IObserver> observer) { @@ -361,7 +361,7 @@ private void Emit(IObserver> observer) } } - /// Range delay projection with no safe-signal wrapper allocation. + /// Emits a whole range as one batch scheduled after the due time. /// The range value type. /// The range source. /// The normalized due time. @@ -531,7 +531,7 @@ private void OnError(Exception error) Dispose(); } - /// Emits the value still waiting inside the quiet window, then forwards completion. + /// Emits any value waiting inside the quiet window, then forwards completion. private void OnCompleted() { lock (_gate) @@ -543,9 +543,9 @@ private void OnCompleted() _done = true; - // The quiet window is cut short by completion rather than cancelled by it: the value it was - // holding is delivered first, matching the sibling EmitIfQuiet operator. A value the timer has - // already delivered cleared _hasLatest under this same gate, so it cannot be emitted twice. + // Completion cuts the quiet window short rather than cancelling it, so a value the window was + // holding is delivered first. A value the timer delivered cleared _hasLatest under this same + // gate, so it cannot be emitted twice. if (_hasLatest) { _hasLatest = false; diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs b/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs index 991cfa71..32bc2f72 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs @@ -53,7 +53,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Coordinates a sampled observable sequence without the anonymous signal wrapper. + /// Coordinates a sampled observable sequence and its tick timer. /// The source value type. /// The source observable. /// The sample period. @@ -73,18 +73,14 @@ private sealed class ProbeCoordinator(IObservable source, TimeSpan period, /// The downstream observer. private readonly IObserver _observer = observer; - /// - /// The synchronization gate. A reentrant monitor is used because emissions are serialized - /// while the gate is held, which a non-reentrant spin lock cannot do safely. - /// + /// The synchronization gate, reentrant because emissions are made while it is held. private readonly Lock _gate = new(); /// The active source subscription. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA2213:Disposable fields should be disposed", - Justification = - "Disposed via the thread-safe Interlocked.Exchange teardown in Dispose; CA2213 does not recognize disposal of a field through Interlocked.Exchange.")] + Justification = "Dispose tears this field down through Interlocked.Exchange.")] private IDisposable? _subscription; /// The active timer. @@ -114,8 +110,8 @@ public void Dispose() } // Latch the terminal state as well as the resources: a source that ignores the disposal of its - // subscription can keep pushing, and its completion or error must not reach an observer that has - // already unsubscribed. The gate is reentrant, so the terminal paths may reach this while holding it. + // subscription can keep pushing, and its completion or error must not reach an unsubscribed observer. + // The gate is reentrant, so a terminal path may reach this while holding it. lock (_gate) { _done = true; diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs index fe02b7ac..66cd1e6c 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs @@ -54,16 +54,15 @@ public IObservable CombineLatest(Func, TResult> resul /// /// or one of its elements is . /// - /// Ranked below the tuple overloads, which are themselves ranked below the selector overloads. Two to - /// sixteen same-typed sources listed inline keep binding to the tuple overload that names each of them; - /// this one takes over past that arity, and whenever the sources arrive as an array. + /// Ranked below the tuple overloads, so two to sixteen same-typed sources listed inline bind to the tuple + /// overload instead; this one takes over past that arity and whenever the sources arrive as an array. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] [OverloadResolutionPriority(-2)] public static IObservable> CombineLatest(params IObservable[] sources) => CombineLatestOf(CombineLatestSources(sources)); - /// Builds the list-valued combine-latest signal for already-validated sources. + /// Builds the list-valued combine-latest signal from validated sources. /// The element type shared by every source. /// The validated source array. /// The combine-latest signal. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs index f16cb135..75860fea 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs @@ -17,7 +17,7 @@ public static partial class LinqExtensions /// System.Reactive-named tuple-returning latest-value combination operators. /// The element type of source 1. /// Source observable 1 whose latest value is combined. - /// Lower overload priority preserves existing selector calls that pass an untyped null selector. + /// Lower overload priority keeps a call that passes an untyped null binding to the selector overload. extension(IObservable source) { /// Combines latest values from 2 observable sources into tuple values. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs index e4bbf65c..f64774a8 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs @@ -11,11 +11,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// System.Reactive-named CombineLatest over ten through sixteen observable sources. Each overload is the -/// System.Reactive spelling of the identically shaped SyncLatest overload and forwards straight to it, -/// so the operator has a single implementation and argument-validation path. -/// +/// System.Reactive-named CombineLatest over ten through sixteen sources, forwarding to SyncLatest. public static partial class LinqExtensions { /// System.Reactive-named latest-value combination operators for ten through sixteen observable sources. @@ -53,7 +49,7 @@ public static partial class LinqExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] public IObservable CombineLatest( IObservable source2, IObservable source3, @@ -109,7 +105,7 @@ public IObservable CombineLatest CombineLatest( IObservable source2, IObservable source3, @@ -169,7 +165,7 @@ public IObservable CombineLatest CombineLatest( IObservable source2, IObservable source3, @@ -233,7 +229,7 @@ public IObservable CombineLatest CombineLatest( IObservable source2, IObservable source3, @@ -301,7 +297,7 @@ public IObservable CombineLatest CombineLatest( IObservable source2, IObservable source3, @@ -373,7 +369,7 @@ public IObservable CombineLatest CombineLatest( IObservable source2, IObservable source3, @@ -449,7 +445,7 @@ public IObservable CombineLatest CombineLatest( IObservable source2, IObservable source3, diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs index 6fdc25d6..390af0f3 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs @@ -238,7 +238,7 @@ public IObservable CombineLatest( [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] public IObservable CombineLatest( IObservable source2, IObservable source3, @@ -298,7 +298,7 @@ public IObservable CombineLatest( [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = "An arity-N combinator takes one observable per source; a parameter object would erase the element type each source contributes to the selector.")] + Justification = "An arity-N combinator takes one observable per source.")] public IObservable CombineLatest( IObservable source2, IObservable source3, diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.SubscribeSafe.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.SubscribeSafe.cs index a90cbe79..c6bd8e73 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.SubscribeSafe.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.SubscribeSafe.cs @@ -142,10 +142,7 @@ public static IDisposable SubscribeSafe( [System.Diagnostics.CodeAnalysis.SuppressMessage( "PublicApi", "PAS0003:Public API differs from the baseline", - Justification = - "This overload and its 'where T : struct' twin have identical signatures apart from the generic " - + "constraint. The baseline records both, but an entry is resolved by the constraint-erased signature " - + "and compared against the first match, so only one of the pair can ever be matched.")] + Justification = "The twin overloads differ only by a generic constraint, which the baseline lookup erases.")] public static IDisposable SubscribeSafe( IObservable source, Action onError, @@ -167,10 +164,7 @@ public static IDisposable SubscribeSafe( [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "Reference-type and value-type constraint overloads (where T : class / where T : struct) of the same " - + "operator. The bodies are necessarily identical, and the two cannot forward to one another because their " - + "generic constraints differ; both must exist so nullable T? resolves for either kind of T.")] + Justification = "The class-constrained and struct-constrained overloads cannot forward to one another.")] public static IDisposable SubscribeSafe( IObservable source, Action onError, @@ -213,17 +207,11 @@ public static IDisposable SubscribeSafe( [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "Reference-type and value-type constraint overloads (where T : class / where T : struct) of the same " - + "operator. The bodies are necessarily identical, and the two cannot forward to one another because their " - + "generic constraints differ; both must exist so nullable T? resolves for either kind of T.")] + Justification = "The class-constrained and struct-constrained overloads cannot forward to one another.")] [System.Diagnostics.CodeAnalysis.SuppressMessage( "PublicApi", "PAS0003:Public API differs from the baseline", - Justification = - "This overload and its 'where T : class' twin have identical signatures apart from the generic " - + "constraint. The baseline records both, but an entry is resolved by the constraint-erased signature " - + "and compared against the first match, so only one of the pair can ever be matched.")] + Justification = "The twin overloads differ only by a generic constraint, which the baseline lookup erases.")] public static IDisposable SubscribeSafe( IObservable source, Action onError, diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs index 55def5f4..08b03163 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs @@ -11,9 +11,8 @@ namespace ReactiveUI.Primitives; #endif /// -/// System.Reactive / LINQ familiar names for the Primitives operator vocabulary. Each method builds the same sink as -/// its Primitives-named counterpart directly, so the two names are interchangeable with identical behaviour and -/// allocation profile. Both name sets are fully supported. +/// System.Reactive and LINQ names for the Primitives operator vocabulary. Each one builds the same sink as its +/// Primitives-named counterpart, so either name can be used interchangeably. /// public static partial class LinqExtensions { @@ -129,8 +128,7 @@ public IObservable Dematerialize() /// or is . /// /// System.Reactive declares an observer-taking SubscribeSafe in the System namespace, so the - /// two are ambiguous whenever both packages are in scope. The other SubscribeSafe shapes here have no - /// System.Reactive counterpart and stay callable under their own name. + /// two are ambiguous whenever both packages are in scope; the other overloads have no such counterpart. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable SubscribeSafePrimitives(IObserver observer) => SubscribeSafeCore(source, observer); @@ -268,9 +266,7 @@ public IObservable Synchronize() [System.Diagnostics.CodeAnalysis.SuppressMessage( "Modernization", "SST2000:Use ArgumentNullException.ThrowIfNull", - Justification = - "ThrowIfNull takes object, so passing the Lock gate triggers CS9216 (unintended monitor-based locking), " - + "which is an error under TreatWarningsAsErrors and cannot be suppressed by attribute.")] + Justification = "ThrowIfNull takes object, so passing the Lock gate is a CS9216 error.")] public IObservable Synchronize(Lock gate) { if (gate is null) @@ -968,8 +964,7 @@ public IObservable Concat() [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style operator and cannot be inferred from the arguments.")] + Justification = "No argument carries the result type, so it can only come from an explicit type argument.")] public IObservable OfType() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -984,8 +979,7 @@ public IObservable OfType() [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style operator and cannot be inferred from the arguments.")] + Justification = "No argument carries the result type, so it can only come from an explicit type argument.")] public IObservable Cast() { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.cs b/src/Primitives.Shared/SignalOperatorParityMixins.cs index 915f1c02..3bacf622 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.cs @@ -239,7 +239,7 @@ public IObservable IgnoreValues() return new IgnoreValuesSignal(source); } - /// Emits the supplied value if the source completes without values. + /// Emits if the source completes without values. /// A sequence that emits when the source is empty. /// The receiver sequence is . public IObservable DefaultIfEmpty() @@ -690,7 +690,7 @@ public IObservable> TimeInterval(ISequencer? scheduler) : new TimeIntervalSignal(source, scheduler); } - /// Combines latest values from both sources. Alias for latest-fusion vocabulary. + /// Combines the latest values from both sources into a projected result. /// The right value type. /// The result value type. /// The right sequence. @@ -760,7 +760,7 @@ public IObservable ForkJoin( } } - /// Task-compatibility helpers for migrations from System.Reactive. + /// Conversion operators that expose a task under the System.Reactive names. /// The task result type. /// The task. extension(Task task) @@ -771,7 +771,7 @@ public IObservable ForkJoin( [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = "Synchronous read is limited to the already-completed task fast path.")] + Justification = "The read is guarded by a RanToCompletion check, so the task is done.")] public IObservable ToObservable() { ArgumentExceptionHelper.ThrowIfNull(task); @@ -791,13 +791,13 @@ public IObservable ToObservable() : new TaskInstanceSignal(task); } - /// Identity helper that keeps source-compatible FirstAsync().ToTask() migrations compiling. + /// Returns the task unchanged, so a FirstAsync().ToTask() chain resolves. /// The supplied task. /// The receiver task is . public Task ToTask() => task ?? throw new ArgumentNullException(nameof(task)); /// Returns a task that mirrors the supplied task but transitions to the canceled state when - /// is canceled first; keeps source-compatible FirstAsync().ToTask(token) migrations compiling. + /// is canceled first. /// The token used to cancel the returned task. /// The supplied task, or a task that completes with the supplied task's outcome or cancels when is canceled. /// The receiver task is . @@ -816,7 +816,7 @@ public Task ToTask(CancellationToken cancellationToken) } } - /// Stamps a value with the supplied scheduler's current time. A non-capturing selector reused by Timestamp via MapWith. + /// Stamps a value with the supplied scheduler's current time. /// The value type. /// The sequencer that supplies the timestamp. /// The value to stamp. @@ -837,8 +837,8 @@ private static async Task AwaitWithCancellationAsync(Task task, Cancell } catch (OperationCanceledException) { - // The wait was abandoned while the underlying task may still be running; observe any later - // fault so it cannot surface as an UnobservedTaskException on the finalizer thread. + // The wait is abandoned while the underlying task can keep running, so observe any later fault + // to keep it from surfacing as an UnobservedTaskException on the finalizer thread. _ = task.ContinueWith( static abandoned => _ = abandoned.Exception, CancellationToken.None, diff --git a/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs b/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs index 9d5a8465..bc2c2f7f 100644 --- a/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs +++ b/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs @@ -174,7 +174,7 @@ public void Dispose() _inner.Dispose(); } - /// Queues a value when the signal is still accepting input. + /// Queues a value while the signal accepts input. /// The value to enqueue. /// when the value was queued; otherwise, . private bool Enqueue(T value) @@ -192,12 +192,10 @@ private bool Enqueue(T value) } } - /// Dequeue and forwards values while capacity is available. + /// Forwards queued values downstream while capacity is available. /// - /// Only a single thread ever delivers downstream at a time. A caller that finds a drain - /// already in progress hands its work to the active owner and returns; the owner re-checks - /// for newly queued work under the gate before relinquishing ownership, so no wakeup is lost - /// and no two threads can deliver to the inner signal concurrently. + /// Only one thread delivers downstream at a time: a caller that finds a drain in progress leaves its work to the + /// owner, and the owner re-checks for queued work under the gate before relinquishing ownership, so no wakeup is lost. /// private void YieldUntilEmptyOrBlocked() { @@ -356,10 +354,7 @@ private void Deliver(DrainItem item) } } - /// - /// A captured value or terminal notification to deliver outside the gate. A readonly struct: one is produced - /// per delivered value on the drain path, so it is passed by value instead of allocating per item. - /// + /// A value or terminal notification captured under the gate and delivered outside it. private readonly record struct DrainItem { /// An empty drain item used for failed capture paths. diff --git a/src/Primitives.Shared/Signals/ReplaySignal{T}.cs b/src/Primitives.Shared/Signals/ReplaySignal{T}.cs index a6026a4b..06b85cd6 100644 --- a/src/Primitives.Shared/Signals/ReplaySignal{T}.cs +++ b/src/Primitives.Shared/Signals/ReplaySignal{T}.cs @@ -11,60 +11,55 @@ namespace ReactiveUI.Primitives.Signals; #endif /// A signal that replays buffered values to new subscribers. -/// The Type. +/// The element type. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ReplaySignal : ISignal { - /// Stores state for the signal implementation. + /// The maximum number of values replayed to a new subscriber. private readonly int _bufferSize; - /// Stores state for the signal implementation. + /// The maximum age of a replayed value. private readonly TimeSpan _window; - /// Stores state for the signal implementation. + /// The clock reading that buffered intervals are measured from. private readonly DateTimeOffset _startTime; - /// Stores state for the signal implementation. + /// The sequencer supplying the clock used for window trimming. private readonly ISequencer _scheduler; - /// Stores state for the signal implementation. + /// Whether a finite replay window is in effect. private readonly bool _usesWindow; - /// Executes the new operation. - /// The result. + /// Serializes buffer mutation, broadcast and subscription. private readonly Lock _observerLock = new(); - /// Stores state for the signal implementation. + /// The observers values are broadcast to. private Broadcaster _broadcaster; - /// Stores state for the signal implementation. + /// Whether the signal has terminated. private bool _isStopped; - /// Stores state for the signal implementation. + /// The terminal error replayed to later subscribers. private Exception? _lastError; - /// Stores state for the signal implementation. + /// The buffered values and their intervals, used when a window or an unbounded buffer is in effect. private Queue>? _queue; - /// Stores state for the signal implementation. + /// The fixed-size ring buffer, used when the buffer is bounded and no window is in effect. private T[]? _ring; - /// Stores state for the signal implementation. + /// The number of values held in the ring. private int _ringCount; - /// Stores state for the signal implementation. + /// The ring index the next value is written to. private int _ringNext; /// Initializes a new instance of the class. - /// Size of the buffer. - /// The window. - /// The scheduler. - /// - /// bufferSize - /// or - /// window. - /// - /// scheduler. + /// The maximum number of values to replay. + /// The maximum age of a replayed value. + /// The sequencer supplying the clock used for window trimming. + /// or is negative. + /// is . public ReplaySignal(int bufferSize, TimeSpan window, ISequencer scheduler) { ArgumentOutOfRangeExceptionHelper.ThrowIfNegative(bufferSize); @@ -88,8 +83,8 @@ public ReplaySignal(int bufferSize, TimeSpan window, ISequencer scheduler) } /// Initializes a new instance of the class. - /// Size of the buffer. - /// The window. + /// The maximum number of values to replay. + /// The maximum age of a replayed value. public ReplaySignal(int bufferSize, TimeSpan window) : this(bufferSize, window, Sequencer.CurrentThread) { @@ -102,50 +97,44 @@ public ReplaySignal() } /// Initializes a new instance of the class. - /// The scheduler. + /// The sequencer supplying the clock used for window trimming. public ReplaySignal(ISequencer scheduler) : this(int.MaxValue, TimeSpan.MaxValue, scheduler) { } /// Initializes a new instance of the class. - /// Size of the buffer. - /// The scheduler. + /// The maximum number of values to replay. + /// The sequencer supplying the clock used for window trimming. public ReplaySignal(int bufferSize, ISequencer scheduler) : this(bufferSize, TimeSpan.MaxValue, scheduler) { } /// Initializes a new instance of the class. - /// Size of the buffer. + /// The maximum number of values to replay. public ReplaySignal(int bufferSize) : this(bufferSize, TimeSpan.MaxValue, Sequencer.CurrentThread) { } /// Initializes a new instance of the class. - /// The window. - /// The scheduler. + /// The maximum age of a replayed value. + /// The sequencer supplying the clock used for window trimming. public ReplaySignal(TimeSpan window, ISequencer scheduler) : this(int.MaxValue, window, scheduler) => _window = window; /// Initializes a new instance of the class. - /// The window. + /// The maximum age of a replayed value. public ReplaySignal(TimeSpan window) : this(int.MaxValue, window, Sequencer.CurrentThread) { } - /// Gets a value indicating whether this instance has observers. - /// - /// true if this instance has observers; otherwise, false. - /// + /// Gets a value indicating whether the signal has observers and has not terminated. public bool HasObservers => _broadcaster.HasObservers && !_isStopped; /// Gets a value indicating whether this instance is disposed. - /// - /// true if this instance is disposed; otherwise, false. - /// public bool IsDisposed { get; private set; } /// Gets the debugger display text. @@ -153,7 +142,7 @@ public ReplaySignal(TimeSpan window) [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Releases unmanaged and - optionally - managed resources. + /// Drops the buffered values and observers and marks the signal disposed. public void Dispose() { if (IsDisposed) @@ -174,7 +163,7 @@ public void Dispose() IsDisposed = true; } - /// Called when [completed]. + /// Terminates the signal and completes every observer; later calls are ignored. public void OnCompleted() { lock (_observerLock) @@ -196,9 +185,9 @@ public void OnCompleted() } } - /// Called when [error]. - /// The exception. - /// error. + /// Terminates the signal with the error, forwarding it to every observer and replaying it to later subscribers. + /// The terminating exception. + /// is . public void OnError(Exception error) { ArgumentExceptionHelper.ThrowIfNull(error); @@ -223,14 +212,11 @@ public void OnError(Exception error) } } - /// Called when [next]. - /// The value. + /// Buffers the value for replay and broadcasts it to the current observers. + /// The value to emit. /// - /// The buffer append and the broadcast happen together under , which is the - /// same gate holds while it adds an observer and replays the buffer. A value is - /// therefore atomically either buffered-and-broadcast before a new observer is added (so that observer - /// receives it only via replay) or buffered-and-broadcast after the observer's replay completes (so it - /// receives it only live) - never both, and never out of order. + /// The append and the broadcast share the gate holds while it attaches an observer and + /// replays the buffer, so a value reaches a new observer either through replay or live, never both or out of order. /// public void OnNext(T value) { @@ -258,10 +244,10 @@ public void OnNext(T value) } } - /// Subscribes the specified observer. - /// The observer. - /// A Disposable. - /// observer. + /// Replays the buffered values to the observer, then attaches it unless the signal has terminated. + /// The observer to attach. + /// A disposable that detaches the observer, or an empty disposable when the signal has terminated. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -310,8 +296,8 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the ThrowIfDisposed operation. - /// The signal has already been disposed. + /// Throws when the signal has been disposed. + /// The signal has been disposed. private void ThrowIfDisposed() { if (!IsDisposed) @@ -322,7 +308,7 @@ private void ThrowIfDisposed() throw new ObjectDisposedException(string.Empty); } - /// Executes the Trim operation. + /// Drops queued values beyond the buffer size and older than the replay window. private void Trim() { while (_queue!.Count > _bufferSize) @@ -343,8 +329,8 @@ private void Trim() } } - /// Executes the AppendToRing operation. - /// The value. + /// Writes the value into the ring, overwriting the oldest entry once it is full. + /// The value to buffer. private void AppendToRing(T value) { var ring = _ring!; @@ -368,8 +354,8 @@ private void AppendToRing(T value) _ringCount++; } - /// Executes the ReplayRing operation. - /// The observer value. + /// Replays the ring contents to the observer in arrival order. + /// The observer receiving the replay. private void ReplayRing(IObserver observer) { var ring = _ring!; @@ -395,26 +381,25 @@ private void ReplayRing(IObserver observer) } } - /// Represents the ObserverHandler class. - /// The subject value. - /// The observer value. + /// Detaches one observer from the signal when disposed. + /// The signal the observer is attached to. + /// The observer to detach. [SuppressMessage( "Usage", "CA2213:Disposable fields should be disposed", - Justification = "_subject is the signal that owns this subscription, not a resource it owns; disposing it would tear down the signal when one observer unsubscribes.")] + Justification = "The field references the owning signal, not a resource this subscription owns.")] private sealed class ObserverHandler(ReplaySignal subject, IObserver observer) : IDisposable { - /// Executes the new operation. - /// The result. + /// Serializes concurrent disposal. private readonly Lock _lock = new(); - /// Stores state for the signal implementation. + /// The signal the observer is attached to; null once disposed. private ReplaySignal? _subject = subject; - /// Stores state for the signal implementation. + /// The observer to detach; null once disposed. private IObserver? _observer = observer; - /// Executes the Dispose operation. + /// Removes the observer from the signal and clears both references. public void Dispose() { lock (_lock) diff --git a/src/Primitives.Shared/Signals/ScheduledSignal{T}.cs b/src/Primitives.Shared/Signals/ScheduledSignal{T}.cs index 210ff458..6e0a4983 100644 --- a/src/Primitives.Shared/Signals/ScheduledSignal{T}.cs +++ b/src/Primitives.Shared/Signals/ScheduledSignal{T}.cs @@ -18,22 +18,22 @@ public class ScheduledSignal : ISignal /// Guards default-observer and subscription-count state. private readonly Lock _observerLock = new(); - /// Stores the fallback observer for the signal implementation. + /// The observer that receives values while no other subscriber is active. private readonly IObserver? _defaultObserver; - /// Stores the scheduler for the signal implementation. + /// The sequencer notifications are emitted on. private readonly ISequencer _scheduler; - /// Stores the underlying signal implementation. + /// The backing signal this instance wraps. private readonly ISignal _subject; - /// Stores the active non-default observer count. + /// The number of active non-default observers. private int _observerRefCount; - /// Stores the active default-observer subscription. + /// The default observer's subscription, or null while other subscribers are attached. private IDisposable? _defaultObserverSub; - /// Stores state for the signal implementation. + /// Whether this instance has been disposed. private bool _isDisposed; /// Initializes a new instance of the class. @@ -164,8 +164,8 @@ protected virtual void Dispose(bool disposing) } /// Subscribes the default observer through the configured scheduler. - /// The observer value. - /// The subscription value. + /// The default observer. + /// The subscription to dispose once another observer arrives. [MethodImpl(MethodImplOptions.AggressiveInlining)] private IDisposable SubscribeDefaultObserver(IObserver observer) => _subject.ObserveOn(_scheduler).Subscribe(observer); @@ -180,7 +180,7 @@ private void ReleaseObserver() } /// Releases one observer while the observer lock is held. - /// The default observer value. + /// The default observer to resubscribe once the last observer leaves, or null when there is none. private void ReleaseObserverLocked(IObserver? defaultObserver) { if (_observerRefCount > 0) @@ -196,7 +196,7 @@ private void ReleaseObserverLocked(IObserver? defaultObserver) _defaultObserverSub = SubscribeDefaultObserver(defaultObserver); } - /// Executes the ThrowIfDisposed operation. + /// Throws when this instance or its backing signal has been disposed. /// This instance or its backing signal has been disposed. private void ThrowIfDisposed() { diff --git a/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs b/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs index 82d98c0a..e4a506ca 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs @@ -19,9 +19,9 @@ public static partial class SignalExtensions /// The source signal. extension(IObservable source) { - /// Witnesses the on. - /// The scheduler. - /// An Observable. + /// Delivers the source notifications on the supplied sequencer. + /// The sequencer that notifications are delivered on. + /// A signal that forwards the source on . [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable WitnessOn(ISequencer scheduler) => new WitnessOnSignal(source, scheduler); diff --git a/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs b/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs index dd08c445..52c95051 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs @@ -18,21 +18,17 @@ public static partial class SignalExtensions /// The asynchronous task. extension(IObservable asyncTask) { - /// Handles the cancellation. - /// The token. - /// - /// A Task. - /// + /// Awaits the source and returns its final value, or the default value when the token cancels the wait. + /// The token that cancels the wait. + /// A task carrying the final source value, or the default value on cancellation. [MethodImpl(MethodImplOptions.AggressiveInlining)] public Task HandleCancellation(CancellationToken token) => asyncTask.HandleCancellation(null, token); - /// Handles the cancellation. - /// The action. - /// The token. - /// - /// A Task. - /// + /// Awaits the source and returns its final value; on cancellation invokes the action and returns the default value. + /// Invoked when the wait is cancelled. + /// The token that cancels the wait. + /// A task carrying the final source value, or the default value on cancellation. public async Task HandleCancellation(Action? action, CancellationToken token) { try @@ -53,14 +49,14 @@ public static partial class SignalExtensions /// The asynchronous task. extension(Task asyncTask) { - /// Handles the cancellation. - /// A Task. + /// Awaits the task and completes normally when it is cancelled. + /// A task that completes once the awaited task finishes or is cancelled. [MethodImpl(MethodImplOptions.AggressiveInlining)] public Task HandleCancellation() => asyncTask.HandleCancellation(null); - /// Handles the cancellation. - /// The action. - /// A Task. + /// Awaits the task and invokes the action instead of throwing when it is cancelled. + /// Invoked when the task is cancelled. + /// A task that completes once the awaited task finishes or is cancelled. public async Task HandleCancellation(Action? action) { try @@ -79,14 +75,14 @@ public async Task HandleCancellation(Action? action) /// The asynchronous task. extension(Task asyncTask) { - /// Handles the cancellation. - /// A Task of TResult. + /// Awaits the task and returns its result, or the default value when it is cancelled. + /// A task carrying the result, or the default value on cancellation. [MethodImpl(MethodImplOptions.AggressiveInlining)] public Task HandleCancellation() => asyncTask.HandleCancellation(null); - /// Handles the cancellation. - /// The action. - /// A Task of TResult. + /// Awaits the task and returns its result; on cancellation invokes the action and returns the default value. + /// Invoked when the task is cancelled. + /// A task carrying the result, or the default value on cancellation. public async Task HandleCancellation(Action? action) { try @@ -101,9 +97,9 @@ public async Task HandleCancellation(Action? action) return default; } - /// Executes the WhenCancelled operation. - /// The cancellationToken value. - /// The result. + /// Awaits whichever completes first, the task or the token, and reports which one it was. + /// The token raced against the task. + /// The task result, and whether the wait ended in cancellation. internal async Task<(TResult Value, bool IsCanceled)> WhenCancelled(CancellationToken cancellationToken) { TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs b/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs index a6457b23..8172b432 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs @@ -23,7 +23,7 @@ public static partial class SignalExtensions /// This operation subscribes to the observable sequence, making it hot. /// /// A final signal awaiter. - /// source. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public IAwaitSignal GetAwaiter() => Signal.RunAsync(source); @@ -32,11 +32,9 @@ public IAwaitSignal GetAwaiter() => /// Gets an awaiter that returns the last value of the observable sequence or throws an exception if the sequence is empty. /// This operation subscribes to the observable sequence, making it hot. /// - /// Cancellation token. - /// - /// A final signal awaiter. - /// - /// source. + /// Token that cancels the await and disposes the subscription. + /// A final signal awaiter. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public IAwaitSignal GetAwaiter(CancellationToken cancellationToken) => Signal.RunAsync(source, cancellationToken); diff --git a/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs b/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs index fac3b590..a15b63e8 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs @@ -51,9 +51,9 @@ public IObservable Recover(Func(source, handler); } - /// Finallies the specified finally action. - /// The finally action. - /// An observable sequence containing elements from consecutive source sequences until a source sequence terminates successfully. + /// Invokes an action when the subscription is torn down, whether the source terminated or the subscription was disposed. + /// The action to invoke on teardown. + /// An observable sequence that forwards the source and invokes on teardown. public IObservable OnCleanup(Action finallyAction) { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/Primitives.Shared/Signals/Signal{Collect}.cs b/src/Primitives.Shared/Signals/Signal{Collect}.cs index c84636f3..2d7c3d8a 100644 --- a/src/Primitives.Shared/Signals/Signal{Collect}.cs +++ b/src/Primitives.Shared/Signals/Signal{Collect}.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Signals; namespace ReactiveUI.Primitives.Signals; #endif -/// Create Signals functionality. +/// Time-windowed buffering support for the signal factory surface. public static partial class Signal { /// Coordinates time-windowed buffering for a single subscription. @@ -38,7 +38,7 @@ internal sealed class CollectCoordinator(IObserver> obse /// The values collected for the current window. private readonly List _values = []; - /// Whether a flush has already been scheduled for the current window. + /// Whether a flush is scheduled for the current window. private bool _flushScheduled; /// Whether the source has terminated. @@ -89,7 +89,7 @@ private void OnCompleted() _observer.OnCompleted(); } - /// Flushes the current window if it still has buffered values. + /// Emits the current window when it holds buffered values. private void Flush() { var batch = TakeScheduledBatch(); diff --git a/src/Primitives.Shared/Signals/Signal{Create}.cs b/src/Primitives.Shared/Signals/Signal{Create}.cs index 854c0777..61632298 100644 --- a/src/Primitives.Shared/Signals/Signal{Create}.cs +++ b/src/Primitives.Shared/Signals/Signal{Create}.cs @@ -12,17 +12,16 @@ namespace ReactiveUI.Primitives.Reactive.Signals; namespace ReactiveUI.Primitives.Signals; #endif -/// Create Signals functionality. +/// Factory methods that build signals from subscribe functions and deferred sources. public static partial class Signal { /// - /// Create anonymous Signals. Observer has exception durability. - /// This is recommended for make operator and event, generating a HotSignals. + /// Creates a signal that runs the subscribe function for each observer. A downstream OnNext that throws + /// leaves the subscription intact, which suits hot signals built over operators and events. /// - /// The type. - /// The subscribe. - /// An Signals. - /// subscribe. + /// The element type. + /// Invoked for each observer; returns the disposable that releases the subscription. + /// A signal backed by . /// is null. public static IObservable Create(Func, IDisposable> subscribe) { @@ -51,10 +50,7 @@ public static IObservable Create(Func, Task> sub [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "A deliberate overload accepting a cancellable subscribe delegate. The body matches the non-cancellable " - + "overload because AsyncCreateSignal exposes a constructor for each delegate shape; the two take " - + "different delegate types and cannot forward to one another.")] + Justification = "The overloads take different delegate types and cannot forward to one another.")] public static IObservable Create(Func, CancellationToken, Task> subscribe) { ArgumentExceptionHelper.ThrowIfNull(subscribe); @@ -63,14 +59,13 @@ public static IObservable Create(Func, CancellationToken, Tas } /// - /// Create anonymous Signals. Observer has exception durability. - /// This is recommended for make operator and event, generating a HotSignals. + /// Creates a signal that runs the subscribe function for each observer. A downstream OnNext that throws + /// leaves the subscription intact, which suits hot signals built over operators and events. /// - /// The type. - /// The subscribe. - /// if set to true [is required subscribe on current thread]. - /// An Signals. - /// subscribe. + /// The element type. + /// Invoked for each observer; returns the disposable that releases the subscription. + /// Whether subscription must be dispatched through the current-thread sequencer. + /// A signal backed by . /// is null. public static IObservable Create( Func, IDisposable> subscribe, @@ -82,15 +77,14 @@ public static IObservable Create( } /// - /// Create anonymous Signals. Observer has exception durability. - /// This is recommended for make operator and event, generating a HotSignals. + /// Creates a signal that passes the state to the subscribe function for each observer, so the function can be + /// static instead of capturing a closure. /// - /// The type. - /// The type of the state. - /// The state. - /// The subscribe. - /// An Signals. - /// subscribe. + /// The element type. + /// The type of the captured state. + /// The state passed to on each subscription. + /// Invoked for each observer; returns the disposable that releases the subscription. + /// A signal backed by . /// is null. public static IObservable CreateWithState( TState state, @@ -102,16 +96,15 @@ public static IObservable CreateWithState( } /// - /// Create anonymous Signals. Observer has exception durability. - /// This is recommended for make operator and event, generating a HotSignals. + /// Creates a signal that passes the state to the subscribe function for each observer, so the function can be + /// static instead of capturing a closure. /// - /// The type. - /// The type of the state. - /// The state. - /// The subscribe. - /// if set to true [is required subscribe on current thread]. - /// An Signals. - /// subscribe. + /// The element type. + /// The type of the captured state. + /// The state passed to on each subscription. + /// Invoked for each observer; returns the disposable that releases the subscription. + /// Whether subscription must be dispatched through the current-thread sequencer. + /// A signal backed by . /// is null. public static IObservable CreateWithState( TState state, @@ -124,13 +117,12 @@ public static IObservable CreateWithState( } /// - /// Create anonymous Signals. Safe means auto detach when error raised in onNext pipeline. - /// This is recommended for making a ColdSignals. + /// Creates a signal that runs the subscribe function for each observer and releases the subscription when a + /// downstream OnNext throws, which suits cold signals. /// - /// The type. - /// The subscribe. - /// An Signals. - /// subscribe. + /// The element type. + /// Invoked for each observer; returns the disposable that releases the subscription. + /// A signal backed by . /// is null. public static IObservable CreateSafe(Func, IDisposable> subscribe) { @@ -140,14 +132,13 @@ public static IObservable CreateSafe(Func, IDisposable> subsc } /// - /// Create anonymous Signals. Safe means auto detach when error raised in onNext pipeline. - /// This is recommended for making a ColdSignals. + /// Creates a signal that runs the subscribe function for each observer and releases the subscription when a + /// downstream OnNext throws, which suits cold signals. /// - /// The type. - /// The subscribe. - /// if set to true [is required subscribe on current thread]. - /// An Observable. - /// subscribe. + /// The element type. + /// Invoked for each observer; returns the disposable that releases the subscription. + /// Whether subscription must be dispatched through the current-thread sequencer. + /// A signal backed by . /// is null. public static IObservable CreateSafe( Func, IDisposable> subscribe, @@ -159,9 +150,9 @@ public static IObservable CreateSafe( } /// Lazily creates the source sequence for each subscription. - /// The type. - /// The observable factory. - /// An Observable. + /// The element type. + /// Invoked once per subscription to build the source. + /// A signal that subscribes to the factory-produced source for each observer. public static IObservable Lazy(Func> observableFactory) { ArgumentExceptionHelper.ThrowIfNull(observableFactory); @@ -189,10 +180,7 @@ public static IObservable Defer(Func>> observableFacto [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "A deliberate overload accepting a cancellable factory delegate. The body matches the non-cancellable " - + "overload because AsyncDeferSignal exposes a constructor for each delegate shape; the two take " - + "different delegate types and cannot forward to one another.")] + Justification = "The overloads take different delegate types and cannot forward to one another.")] public static IObservable Defer(Func>> observableFactory) { ArgumentExceptionHelper.ThrowIfNull(observableFactory); diff --git a/src/Primitives.Shared/Signals/Signal{Emit}.cs b/src/Primitives.Shared/Signals/Signal{Emit}.cs index 6d24f725..b2af64bd 100644 --- a/src/Primitives.Shared/Signals/Signal{Emit}.cs +++ b/src/Primitives.Shared/Signals/Signal{Emit}.cs @@ -13,47 +13,47 @@ namespace ReactiveUI.Primitives.Signals; /// Provides static factory and operator methods for signals. public static partial class Signal { - /// Emit a single value on the specified scheduler. - /// The type. - /// The value. - /// The scheduler. - /// An Signals. + /// Creates a signal that emits a single value on the supplied scheduler and completes. + /// The element type. + /// The value to emit. + /// The scheduler the value is emitted on. + /// A signal that emits and completes. public static IObservable Emit(T value, ISequencer scheduler) => scheduler == Sequencer.Immediate ? new ImmediateReturnSignal(value) : new ReturnSignal(value, scheduler); - /// Emit a single value immediately. - /// The type. - /// The value. - /// An Signals. + /// Creates a signal that emits a single value to each subscriber and completes, without scheduling. + /// The element type. + /// The value to emit. + /// A signal that emits and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable Emit(T value) => new ImmediateReturnSignal(value); - /// Emit a single RxVoid value immediately, optimized for no allocation. - /// The value. - /// An Signals. + /// Returns the shared signal that emits the unit value and completes, without allocating. + /// The unit value to emit. + /// A signal that emits and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable Emit(RxVoid value) => ImmutableReturnRxVoidSignal.Instance; - /// Emit a single Boolean value immediately, optimized for no allocation. - /// if set to true [value]. - /// An Signals. + /// Returns one of the two shared Boolean signals that emit the value and complete, without allocating. + /// The value to emit. + /// A signal that emits and completes. public static IObservable Emit(bool value) => value ? ImmutableReturnTrueSignal.Instance : ImmutableReturnFalseSignal.Instance; - /// Emit a single Int32 value immediately, optimized for cached values. - /// The value. - /// An Signals. + /// Creates a signal that emits a single 32-bit integer and completes, without scheduling. + /// The value to emit. + /// A signal that emits and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable Emit(int value) => new ImmediateReturnSignal(value); - /// Same as Signals.Emit(RxVoid.Default); but no allocate memory. - /// An Signals. + /// Returns the shared signal that emits the unit value and completes, without allocating. + /// A signal that emits and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable EmitRxVoid() => ImmutableReturnRxVoidSignal.Instance; diff --git a/src/Primitives.Shared/Signals/Signal{Factories}.cs b/src/Primitives.Shared/Signals/Signal{Factories}.cs index d07e1dfd..ba4f3e45 100644 --- a/src/Primitives.Shared/Signals/Signal{Factories}.cs +++ b/src/Primitives.Shared/Signals/Signal{Factories}.cs @@ -18,7 +18,7 @@ public static partial class Signal /// Creates a finite integer signal from for values. /// The first value to emit. /// The number of values to emit. - /// An Signals. + /// A signal that emits consecutive integers and completes. public static IObservable Sequence(int start, int count) { ArgumentOutOfRangeExceptionHelper.ThrowIfNegative(count); @@ -29,8 +29,8 @@ public static IObservable Sequence(int start, int count) /// Creates a finite integer signal from for values on . /// The first value to emit. /// The number of values to emit. - /// The scheduler. - /// An Signals. + /// The scheduler the values are emitted on. + /// A signal that emits consecutive integers and completes. public static IObservable Sequence(int start, int count, ISequencer scheduler) { ArgumentOutOfRangeExceptionHelper.ThrowIfNegative(count); @@ -48,18 +48,18 @@ public static IObservable Sequence(int start, int count, ISequencer schedul } /// Creates a signal that repeats a value forever. - /// The type. - /// The value. - /// An Signals. + /// The element type. + /// The value to repeat. + /// A signal that repeats and never completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable Loop(T value) => new LoopSignal(value); /// Creates a signal that repeats a value times. - /// The type. - /// The value. + /// The element type. + /// The value to repeat. /// The number of times to repeat the value. - /// An Signals. + /// A signal that emits times and completes. public static IObservable Loop(T value, int count) { ArgumentOutOfRangeExceptionHelper.ThrowIfNegative(count); @@ -74,7 +74,7 @@ public static IObservable Loop(T value, int count) /// The condition that determines whether to continue. /// The function that advances the state. /// The function that produces the result from the state. - /// An Signals. + /// A signal that emits a projection of each state until the condition fails, then completes. public static IObservable Unfold( TState initialState, Func condition, @@ -97,7 +97,7 @@ public static IObservable Unfold( /// The condition that determines whether to continue. /// The function that advances the state. /// The function that produces the result from the state. - /// An Signals. + /// A signal that emits a projection of each state until the condition fails, then completes. public static IObservable Iterate( TState initialState, Func condition, @@ -115,10 +115,10 @@ public static IObservable Iterate( /// Creates a signal whose subscription lifetime owns a resource. /// The type of the resource. - /// The type. + /// The element type. /// The factory that creates the resource. /// The factory that creates the signal from the resource. - /// An Signals. + /// A signal that disposes the resource when the subscription ends. public static IObservable Use( Func resourceFactory, Func> signalFactory) @@ -134,7 +134,7 @@ public static IObservable Use( /// Converts an event into a signal of event pattern values. /// The action that subscribes the event handler. /// The action that unsubscribes the event handler. - /// An Signals. + /// A signal that emits an event pattern for each raised event. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable> FromEventPattern( Action addHandler, @@ -163,7 +163,7 @@ public static IObservable> FromEventPattern( /// The type of the event arguments. /// The action that subscribes the event handler. /// The action that unsubscribes the event handler. - /// An Signals. + /// A signal that emits an event pattern for each raised event. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable> FromEventPattern( Action> addHandler, @@ -202,8 +202,7 @@ public static IObservable> FromEventPattern [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The event argument type is part of the returned EventPattern and must be specified for non-generic event handlers.")] + Justification = "The event argument type appears only in the return type.")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable> FromEventPattern( Action addHandler, @@ -224,8 +223,7 @@ public static IObservable> FromEventPattern> FromEventPattern( Action addHandler, Action removeHandler, @@ -299,7 +297,7 @@ public static IObservable> FromEventPattern> FromEventPattern( Func, TEventHandler> conversion, @@ -323,7 +321,7 @@ public static IObservable> FromEventPattern> FromEventPattern( Func, TEventHandler> conversion, Action addHandler, @@ -401,9 +399,9 @@ public static IObservable FromEvent( } /// Creates a signal from an enumerable sequence. - /// The type. + /// The element type. /// The values to emit. - /// An Signals. + /// A signal that emits each value in order and completes. public static IObservable FromEnumerable(IEnumerable values) { ArgumentExceptionHelper.ThrowIfNull(values); @@ -412,10 +410,10 @@ public static IObservable FromEnumerable(IEnumerable values) } /// Creates a signal from an enumerable sequence and stops enumeration when the token is cancelled. - /// The type. + /// The element type. /// The values to emit. - /// The cancellation token. - /// An Signals. + /// The token that stops enumeration. + /// A signal that emits each value in order and completes. public static IObservable FromEnumerable(IEnumerable values, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(values); @@ -426,14 +424,13 @@ public static IObservable FromEnumerable(IEnumerable values, Cancellati } /// Creates a signal from a task instance. - /// The type. + /// The element type. /// The task to convert. - /// An Signals. + /// A signal that emits the task result and completes, or fails with the task error. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = - "Synchronous read of an already-completed (RanToCompletion) task for an allocation-free fast path; await is invalid in this synchronous factory.")] + Justification = "The result is read only once the task status is RanToCompletion.")] public static IObservable FromTask(Task task) { ArgumentExceptionHelper.ThrowIfNull(task); @@ -454,9 +451,9 @@ public static IObservable FromTask(Task task) } /// Creates a signal by invoking an asynchronous factory at subscription time. - /// The type. + /// The element type. /// The factory that creates the task. - /// An Signals. + /// A signal that emits the task result and completes. public static IObservable FromAsync(Func> taskFactory) { ArgumentExceptionHelper.ThrowIfNull(taskFactory); @@ -465,18 +462,18 @@ public static IObservable FromAsync(Func> taskFactory) } /// Creates a signal by invoking an asynchronous factory at subscription time. - /// The type. + /// The element type. /// The factory that creates the task. - /// An Signals. + /// A signal that emits the task result and completes, cancelling the task when the subscription ends. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable FromAsync(Func> taskFactory) => new FromAsyncSignal(taskFactory); /// Creates a signal by invoking an asynchronous factory at subscription time. - /// The type. + /// The element type. /// The factory that creates the task. - /// The cancellation token. - /// An Signals. + /// The external token that cancels the task. + /// A signal that emits the task result and completes. public static IObservable FromAsync( Func> taskFactory, CancellationToken cancellationToken) @@ -512,10 +509,10 @@ public static IObservable Expire(IObservable source, TimeSpan dueTime, return new ExpireSignal(source, dueTime, scheduler); } - /// Runs a function on the supplied scheduler and emits its result. - /// The type. + /// Runs a function on the default sequencer and emits its result. + /// The element type. /// The function to run. - /// An Signals. + /// A signal that emits the function result and completes. public static IObservable Start(Func function) { ArgumentExceptionHelper.ThrowIfNull(function); @@ -524,10 +521,10 @@ public static IObservable Start(Func function) } /// Runs a function on the supplied scheduler and emits its result. - /// The type. + /// The element type. /// The function to run. - /// The scheduler. - /// An Signals. + /// The scheduler the function runs on. + /// A signal that emits the function result and completes. public static IObservable Start(Func function, ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(function); @@ -537,9 +534,9 @@ public static IObservable Start(Func function, ISequencer scheduler) return new StartSignal(function, scheduler); } - /// Runs an action on the supplied scheduler and emits when it completes. + /// Runs an action on the default sequencer and emits when it completes. /// The action to run. - /// An Signals. + /// A signal that emits the unit value and completes. public static IObservable Start(Action action) { ArgumentExceptionHelper.ThrowIfNull(action); @@ -549,8 +546,8 @@ public static IObservable Start(Action action) /// Runs an action on the supplied scheduler and emits when it completes. /// The action to run. - /// The scheduler. - /// An Signals. + /// The scheduler the action runs on. + /// A signal that emits the unit value and completes. public static IObservable Start(Action action, ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(action); @@ -562,9 +559,9 @@ public static IObservable Start(Action action, ISequencer scheduler) #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER || NET5_0_OR_GREATER /// Creates a signal from an async enumerable sequence and cancels enumeration when disposed. - /// The type. + /// The element type. /// The values to emit. - /// An Signals. + /// A signal that emits each value in order and completes. public static IObservable FromAsyncEnumerable(IAsyncEnumerable values) { ArgumentExceptionHelper.ThrowIfNull(values); @@ -573,10 +570,10 @@ public static IObservable FromAsyncEnumerable(IAsyncEnumerable values) } /// Creates a signal from an async enumerable sequence and cancels enumeration when disposed. - /// The type. + /// The element type. /// The values to emit. - /// The cancellation token. - /// An Signals. + /// The token that stops enumeration. + /// A signal that emits each value in order and completes. public static IObservable FromAsyncEnumerable(IAsyncEnumerable values, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(values); @@ -588,15 +585,15 @@ public static IObservable FromAsyncEnumerable(IAsyncEnumerable values, /// Emits a single zero tick after the due time. /// The relative time after which to emit the tick. - /// An Signals. + /// A signal that emits 0 after and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable After(TimeSpan dueTime) => new AfterSignal(dueTime, ThreadPoolSequencer.Instance); /// Emits a single zero tick after the due time. /// The relative time after which to emit the tick. - /// The scheduler. - /// An Signals. + /// The scheduler the tick is emitted on. + /// A signal that emits 0 after and completes. public static IObservable After(TimeSpan dueTime, ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -606,15 +603,15 @@ public static IObservable After(TimeSpan dueTime, ISequencer scheduler) /// Emits a single zero tick at the specified absolute due time. /// The absolute time at which to emit the tick. - /// An Signals. + /// A signal that emits 0 at and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable After(DateTimeOffset dueTime) => new AfterSignal(Sequencer.Normalize(dueTime - ThreadPoolSequencer.Instance.Now), ThreadPoolSequencer.Instance); /// Emits a single zero tick at the specified absolute due time. /// The absolute time at which to emit the tick. - /// The scheduler. - /// An Signals. + /// The scheduler the tick is emitted on. + /// A signal that emits 0 at and completes. public static IObservable After(DateTimeOffset dueTime, ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -625,7 +622,7 @@ public static IObservable After(DateTimeOffset dueTime, ISequencer schedul /// Emits first after and then at . /// The relative time before the first tick. /// The period between subsequent ticks. - /// An Signals. + /// A signal that emits increasing ticks, the first after . [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable After(TimeSpan dueTime, TimeSpan period) => new AfterSignal(dueTime, period, ThreadPoolSequencer.Instance); @@ -633,8 +630,8 @@ public static IObservable After(TimeSpan dueTime, TimeSpan period) => /// Emits first after and then at . /// The relative time before the first tick. /// The period between subsequent ticks. - /// The scheduler. - /// An Signals. + /// The scheduler the ticks are emitted on. + /// A signal that emits increasing ticks, the first after . public static IObservable After(TimeSpan dueTime, TimeSpan period, ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -644,7 +641,7 @@ public static IObservable After(TimeSpan dueTime, TimeSpan period, ISequen /// Emits monotonically increasing ticks at the specified period. /// The period between ticks. - /// An Signals. + /// A signal that emits increasing ticks every . public static IObservable Every(TimeSpan period) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(period, TimeSpan.Zero); @@ -654,8 +651,8 @@ public static IObservable Every(TimeSpan period) /// Emits monotonically increasing ticks at the specified period. /// The period between ticks. - /// The scheduler. - /// An Signals. + /// The scheduler the ticks are emitted on. + /// A signal that emits increasing ticks every . public static IObservable Every(TimeSpan period, ISequencer scheduler) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(period, TimeSpan.Zero); @@ -666,9 +663,9 @@ public static IObservable Every(TimeSpan period, ISequencer scheduler) } /// Concatenates the supplied signals. - /// The type. + /// The element type. /// The signals to concatenate. - /// An Signals. + /// A signal that subscribes to each source after the previous one completes. public static IObservable Chain(params IObservable[] sources) { var validated = ValidateSources(sources); @@ -677,9 +674,9 @@ public static IObservable Chain(params IObservable[] sources) } /// Merges the supplied signals. - /// The type. + /// The element type. /// The signals to merge. - /// An Signals. + /// A signal that forwards values from every source. public static IObservable Blend(params IObservable[] sources) { var validated = ValidateSources(sources); @@ -688,23 +685,23 @@ public static IObservable Blend(params IObservable[] sources) } /// Races the supplied signals and mirrors the first one to produce a value or terminal signal. - /// The type. + /// The element type. /// The signals to race. - /// An Signals. + /// A signal that mirrors the first source to respond. public static IObservable Race(params IObservable[] sources) { var validated = ValidateSources(sources); return validated.Length > 0 && validated[0] is RangeSignal ? validated[0] : new RaceSignal(validated); } - /// Mirrors the first supplied signal to produce a value or terminal signal. + /// Pairs values from two signals by position and emits the combined result. /// The type of the left signal values. /// The type of the right signal values. /// The type of the result. /// The left signal. /// The right signal. /// The function that combines the paired values. - /// An Signals. + /// A signal that emits one combined result per pair of values. public static IObservable Pair( IObservable left, IObservable right, @@ -729,7 +726,7 @@ public static IObservable Pair( /// The left signal. /// The right signal. /// The function that combines the latest values. - /// An Signals. + /// A signal that emits a combined result whenever either side produces a value. public static IObservable SyncLatest( IObservable left, IObservable right, @@ -754,7 +751,7 @@ public static IObservable SyncLatest( /// The left signal. /// The right signal. /// The function that combines the last values. - /// An Signals. + /// A signal that emits one combined result once both sources complete. public static IObservable ForkJoin( IObservable left, IObservable right, diff --git a/src/Primitives.Shared/Signals/Signal{Fail}.cs b/src/Primitives.Shared/Signals/Signal{Fail}.cs index b069c6cf..b807ac05 100644 --- a/src/Primitives.Shared/Signals/Signal{Fail}.cs +++ b/src/Primitives.Shared/Signals/Signal{Fail}.cs @@ -13,62 +13,54 @@ namespace ReactiveUI.Primitives.Signals; /// Provides static factory and operator methods for signals. public static partial class Signal { - /// Empty Signals. Returns only onError on specified scheduler. - /// The type. - /// The error. - /// The scheduler. - /// An Signals. + /// Creates a signal that emits no values and fails with the supplied error on the supplied scheduler. + /// The element type. + /// The error the signal terminates with. + /// The scheduler the error is emitted on. + /// A signal that terminates with . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Fail(Exception error, ISequencer scheduler) => scheduler == Sequencer.Immediate ? new ImmediateThrowSignal(error) : new ThrowSignal(error, scheduler); - /// Empty Signals. Returns only onError. - /// The type. - /// The error. - /// An Signals. + /// Creates a signal that emits no values and fails with the supplied error on subscription. + /// The element type. + /// The error the signal terminates with. + /// A signal that terminates with . [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Fail(Exception error) => new ImmediateThrowSignal(error); - /// Empty Signals. Returns only onError. witness if for Type inference. - /// The type. - /// The error. - /// The witness. - /// An Signals. + /// Creates a signal that emits no values and fails with the supplied error on subscription. + /// The element type. + /// The error the signal terminates with. + /// An unobserved value whose type fixes . + /// A signal that terminates with . [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "The witness parameter exists only so callers can let T be inferred; it is unused, so the body " - + "intentionally mirrors the witness-less Fail overload. They are distinct Rx-parity overloads that build " - + "the signal directly rather than forwarding.")] + Justification = "The witness parameter only fixes the element type, so this overload builds the same signal.")] public static IObservable Fail(Exception error, T witness) => new ImmediateThrowSignal(error); - /// Empty Signals. Returns only onError on specified scheduler. witness if for Type inference. - /// The type. - /// The error. - /// The scheduler. - /// The witness. - /// An Signals. + /// Creates a signal that emits no values and fails with the supplied error on the supplied scheduler. + /// The element type. + /// The error the signal terminates with. + /// The scheduler the error is emitted on. + /// An unobserved value whose type fixes . + /// A signal that terminates with . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "The witness parameter exists only so callers can let T be inferred; it is unused, so the body " - + "intentionally mirrors the scheduler Fail overload. They are distinct Rx-parity overloads that build " - + "the signal directly rather than forwarding.")] + Justification = "The witness parameter only fixes the element type, so this overload builds the same signal.")] public static IObservable Fail(Exception error, ISequencer scheduler, T witness) => scheduler == Sequencer.Immediate ? new ImmediateThrowSignal(error) diff --git a/src/Primitives.Shared/Signals/Signal{FromTask}.cs b/src/Primitives.Shared/Signals/Signal{FromTask}.cs index b6fb3706..201b5645 100644 --- a/src/Primitives.Shared/Signals/Signal{FromTask}.cs +++ b/src/Primitives.Shared/Signals/Signal{FromTask}.cs @@ -13,34 +13,28 @@ namespace ReactiveUI.Primitives.Signals; /// Provides static factory and operator methods for signals. public static partial class Signal { - /// Handles Asnyc Tasks with cancellation. + /// Creates a task-backed signal that runs the function on subscription, handing it the cancellation source disposal cancels. /// The function to execute. - /// - /// An ITaskSignal of T. - /// + /// A signal that emits the function's result and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal FromTask(Func> execution) => FromTask(execution, null, null); - /// Handles Asnyc Tasks with cancellation. + /// Creates a task-backed signal that runs the function on subscription and notifies on the supplied sequencer. /// The function to execute. - /// The scheduler. - /// - /// An ITaskSignal of T. - /// + /// The sequencer notifications are delivered on, or for the current thread. + /// A signal that emits the function's result and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal FromTask( Func> execution, ISequencer? scheduler) => FromTask(execution, scheduler, null); - /// Handles Asnyc Tasks with cancellation. + /// Creates a task-backed signal that runs the function on subscription, observing the supplied cancellation source. /// The function to execute. - /// The scheduler. - /// The cancellation token source. - /// - /// An ITaskSignal of T. - /// + /// The sequencer notifications are delivered on, or for the current thread. + /// The cancellation source handed to the function, or to own a new one. + /// A signal that emits the function's result and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal FromTask( Func> execution, @@ -48,37 +42,31 @@ public static ITaskSignal FromTask( CancellationTokenSource? cancellationTokenSource) => CreateTaskSignal(execution, scheduler, cancellationTokenSource); - /// Froms the asynchronous. + /// Creates a task-backed signal that runs the function on subscription, handing it the cancellation source disposal cancels. /// The type of the return value. - /// The action asynchronous. - /// - /// An TaskSignal of T. - /// + /// The function to execute. + /// A signal that emits the function's result and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal FromTask(Func> actionAsync) => FromTask(actionAsync, null, null); - /// Froms the asynchronous. + /// Creates a task-backed signal that runs the function on subscription and notifies on the supplied sequencer. /// The type of the return value. - /// The action asynchronous. - /// The scheduler. - /// - /// An TaskSignal of T. - /// + /// The function to execute. + /// The sequencer notifications are delivered on, or for the current thread. + /// A signal that emits the function's result and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal FromTask( Func> actionAsync, ISequencer? scheduler) => FromTask(actionAsync, scheduler, null); - /// Froms the asynchronous. + /// Creates a task-backed signal that runs the function on subscription, observing the supplied cancellation source. /// The type of the return value. - /// The action asynchronous. - /// The scheduler. - /// The cancellation token source. - /// - /// An TaskSignal of T. - /// + /// The function to execute. + /// The sequencer notifications are delivered on, or for the current thread. + /// The cancellation source handed to the function, or to own a new one. + /// A signal that emits the function's result and completes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal FromTask( Func> actionAsync, @@ -86,12 +74,12 @@ public static ITaskSignal FromTask( CancellationTokenSource? cancellationTokenSource) => CreateTaskSignal(actionAsync, scheduler, cancellationTokenSource); - /// Executes the CreateTaskSignal operation. - /// The TResult type. - /// The execution value. - /// The scheduler value. - /// The cancellationTokenSource value. - /// The result. + /// Builds the task-backed signal, taking a direct-subscription form for the immediate sequencer. + /// The result type. + /// The function to execute. + /// The sequencer notifications are delivered on. + /// The cancellation source handed to the function. + /// The task-backed signal. private static ITaskSignal CreateTaskSignal( Func> execution, ISequencer? scheduler, @@ -103,12 +91,12 @@ private static ITaskSignal CreateTaskSignal( scheduler, cancellationTokenSource); - /// Executes the SubscribeTask operation. - /// The TResult type. - /// The signal value. - /// The execution value. - /// The observer value. - /// The result. + /// Starts the task for one subscription and forwards its terminal notification to the observer. + /// The result type. + /// The signal whose cancellation source the task runs under. + /// The function producing the task. + /// The observer receiving the result. + /// A disposable that cancels the task when it wins the terminal transition. /// returned . private static IDisposable SubscribeTask( ITaskSignal signal, @@ -155,20 +143,19 @@ private static ActionDisposable CancelOnDispose(TaskStopGate gate, CancellationT Cancel(source); }); - /// Emits a synchronous terminal notification when the task has already finished. - /// The TResult type. + /// Emits the terminal notification synchronously when the task has finished. + /// The result type. /// The task to inspect. - /// The observer value. - /// The token value. + /// The observer receiving the notification. + /// The token checked for cancellation. /// when a synchronous terminal notification was produced. /// - /// Runs before any disposer is handed out, so no dispose race is possible and the emission is ungated. + /// Runs before any disposer is handed out, so no dispose race is possible and the emission needs no gate. /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = - "Synchronous read of an already-completed (RanToCompletion) task for an allocation-free fast path; await is invalid in this synchronous factory.")] + Justification = "The result is read only once the task status is RanToCompletion.")] private static bool TryEmitSynchronously( Task task, IObserver observer, @@ -197,16 +184,15 @@ private static bool TryEmitSynchronously( } /// Observes a pending task and forwards the terminal notification while honoring disposal. - /// The TResult type. - /// The cancellableTask value. - /// The observer value. + /// The result type. + /// The task raced against cancellation. + /// The observer receiving the notification. /// The terminal-notification gate shared with the disposer. - /// The token value. - /// The result. + /// The token checked for cancellation. + /// A task that completes once the notification is forwarded or suppressed. /// - /// The terminal notification is gated on , the same atomic - /// transition the disposer wins when the subscription is torn down. Only the winner emits, so a - /// subscription disposed while the task continuation runs never observes a post-dispose notification. + /// The notification is gated on , the transition the disposer wins on teardown, + /// so a subscription disposed while this continuation runs observes nothing. /// private static async Task ObserveTask( Task<(TResult Value, bool IsCanceled)> cancellableTask, @@ -241,8 +227,8 @@ private static async Task ObserveTask( } } - /// Executes the Cancel operation. - /// The source value. + /// Cancels the source, tolerating a source another completion path disposed. + /// The cancellation source to cancel. private static void Cancel(CancellationTokenSource source) { try @@ -251,15 +237,15 @@ private static void Cancel(CancellationTokenSource source) } catch (ObjectDisposedException) { - // Another completion path already released the token source. + // Another completion path released the token source. } } - /// Immediate task signal that subscribes directly instead of building a nested observable pipeline. + /// Task signal that starts the task in Subscribe rather than through a nested observable pipeline. /// The result type. private sealed class ImmediateTaskSignal : ITaskSignal { - /// Executes the task. + /// The factory that starts the task for each subscription. private readonly Func> _execution; /// Non-zero after disposal. @@ -346,8 +332,8 @@ public void Dispose() SourceCore.Dispose(); } - /// Throws when disposed. - /// The signal has already been disposed. + /// Throws when the signal has been disposed. + /// The signal has been disposed. private void ThrowIfDisposed() { if (!IsDisposed) @@ -361,9 +347,8 @@ private void ThrowIfDisposed() /// Atomic gate that serializes the terminal notification against subscription disposal. /// - /// Mirrors the TaskInstanceSubscription.TryStop() pattern: both the task continuation and the - /// disposer race on a single ; only the winner proceeds, - /// so a notification can never reach a subscription that has already been disposed. + /// The task continuation and the disposer race on a single and only + /// the winner proceeds, so no notification reaches a disposed subscription. /// private sealed class TaskStopGate { diff --git a/src/Primitives.Shared/Signals/Signal{GetAwaiter}.cs b/src/Primitives.Shared/Signals/Signal{GetAwaiter}.cs index e1b23053..0aef67e8 100644 --- a/src/Primitives.Shared/Signals/Signal{GetAwaiter}.cs +++ b/src/Primitives.Shared/Signals/Signal{GetAwaiter}.cs @@ -13,19 +13,19 @@ namespace ReactiveUI.Primitives.Signals; /// Provides static factory and operator methods for signals. public static partial class Signal { - /// Executes the RunAsync operation. - /// The TSource type. - /// The source value. - /// The result. + /// Subscribes to the source and returns an awaiter for its final value. + /// The source value type. + /// The source sequence, subscribed immediately. + /// An awaiter that completes with the final source value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IAwaitSignal RunAsync(IObservable source) => RunAsync(source, CancellationToken.None); - /// Executes the RunAsync operation. - /// The TSource type. - /// The source value. - /// The cancellationToken value. - /// The result. + /// Subscribes to the source and returns an awaiter for its final value, cancelled through the token. + /// The source value type. + /// The source sequence, subscribed immediately. + /// The token that disposes the subscription and faults the awaiter. + /// An awaiter that completes with the final source value, or faults with . public static IAwaitSignal RunAsync( IObservable source, CancellationToken cancellationToken) @@ -101,22 +101,22 @@ public static Task ToTask(IObservable source, Cancell return completion.Attach(subscription, cancellationToken); } - /// Executes the Cancel operation. - /// The T type. - /// The subject value. - /// The cancellationToken value. - /// The result. + /// Faults the awaiter with an carrying the token. + /// The awaited value type. + /// The awaiter to fault. + /// The token reported by the exception. + /// The same awaiter. internal static IAwaitSignal Cancel(IAwaitSignal subject, CancellationToken cancellationToken) { subject.OnError(new OperationCanceledException(cancellationToken)); return subject; } - /// Executes the RegisterCancelation operation. - /// The T type. - /// The subject value. - /// The subscription value. - /// The token value. + /// Cancels the awaiter and disposes the subscription when the token fires, releasing the registration once the awaiter terminates. + /// The awaited value type. + /// The awaiter to cancel. + /// The subscription disposed on cancellation. + /// The token watched for cancellation. internal static void RegisterCancelation( IAwaitSignal subject, IDisposable subscription, diff --git a/src/Primitives.Shared/Signals/Signal{None}.cs b/src/Primitives.Shared/Signals/Signal{None}.cs index 5c2ff7b0..f87bdb8e 100644 --- a/src/Primitives.Shared/Signals/Signal{None}.cs +++ b/src/Primitives.Shared/Signals/Signal{None}.cs @@ -13,51 +13,46 @@ namespace ReactiveUI.Primitives.Signals; /// Provides static factory and operator methods for signals. public static partial class Signal { - /// Empty Signals. Returns only OnCompleted on specified scheduler. - /// The Type. - /// The scheduler. - /// An Signals. + /// Creates a signal that emits no values and completes on the supplied scheduler. + /// The element type. + /// The scheduler the completion is emitted on. + /// A signal that completes without emitting a value. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable None(ISequencer scheduler) => scheduler == Sequencer.Immediate ? ImmutableEmptySignal.Instance : new EmptySignal(scheduler); - /// Empty Signals. Returns only OnCompleted on specified scheduler. witness is for type inference. - /// The Type. - /// The scheduler. - /// The witness. - /// An Signals. + /// Creates a signal that emits no values and completes on the supplied scheduler. + /// The element type. + /// The scheduler the completion is emitted on. + /// An unobserved value whose type fixes . + /// A signal that completes without emitting a value. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "The witness parameter exists only so callers can let T be inferred; it is unused, so the body " - + "intentionally mirrors the scheduler None overload. They are distinct Rx-parity overloads that build " - + "the signal directly rather than forwarding.")] + Justification = "The witness parameter only fixes the element type, so this overload builds the same signal.")] public static IObservable None(ISequencer scheduler, T witness) => scheduler == Sequencer.Immediate ? ImmutableEmptySignal.Instance : new EmptySignal(scheduler); - /// Empty Signals. Returns only OnCompleted. - /// The Type. - /// An Signals. + /// Returns the shared signal that emits no values and completes on subscription. + /// The element type. + /// A signal that completes without emitting a value. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable None() => ImmutableEmptySignal.Instance; - /// Empty Signals. Returns only OnCompleted. witness is for type inference. - /// The Type. - /// The witness. - /// An Signals. + /// Returns the shared signal that emits no values and completes on subscription. + /// The element type. + /// An unobserved value whose type fixes . + /// A signal that completes without emitting a value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable None(T witness) => ImmutableEmptySignal.Instance; diff --git a/src/Primitives.Shared/Signals/Signal{RxAliases}.cs b/src/Primitives.Shared/Signals/Signal{RxAliases}.cs index 149cde95..9d60e386 100644 --- a/src/Primitives.Shared/Signals/Signal{RxAliases}.cs +++ b/src/Primitives.Shared/Signals/Signal{RxAliases}.cs @@ -137,8 +137,7 @@ public static IObservable Repeat(T value, int repeatCount) [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Empty() => ImmutableEmptySignal.Instance; /// Returns an empty observable sequence on the supplied scheduler. @@ -148,8 +147,7 @@ public static IObservable Repeat(T value, int repeatCount) [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Empty(ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -164,8 +162,7 @@ public static IObservable Empty(ISequencer scheduler) [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Never() => ImmutableNeverSignal.Instance; /// Returns an observable sequence that terminates with an error. @@ -176,8 +173,7 @@ public static IObservable Empty(ISequencer scheduler) [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Throw(Exception error) => new ImmediateThrowSignal(error); /// Returns an observable sequence that terminates with a scheduled error. @@ -188,8 +184,7 @@ public static IObservable Empty(ISequencer scheduler) [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Throw(Exception error, ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(scheduler); diff --git a/src/Primitives.Shared/Signals/Signal{Silent}.cs b/src/Primitives.Shared/Signals/Signal{Silent}.cs index 6ce0954b..d502d997 100644 --- a/src/Primitives.Shared/Signals/Signal{Silent}.cs +++ b/src/Primitives.Shared/Signals/Signal{Silent}.cs @@ -13,21 +13,20 @@ namespace ReactiveUI.Primitives.Signals; /// Provides static factory and operator methods for signals. public static partial class Signal { - /// Non-Terminating Signals. It's no returns, never finish. - /// The type. - /// An Signals. + /// Creates a signal that emits nothing and never terminates. + /// The element type. + /// A signal that produces no notifications. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static IObservable Silent() => ImmutableNeverSignal.Instance; - /// Non-Terminating Signals. It's no returns, never finish. witness is for type inference. - /// The type. - /// The witness. - /// An Signals. + /// Creates a signal that emits nothing and never terminates. + /// The element type. + /// An unobserved value whose type fixes . + /// A signal that produces no notifications. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable Silent(T witness) => ImmutableNeverSignal.Instance; } diff --git a/src/Primitives.Shared/Signals/Signal{Subjects}.cs b/src/Primitives.Shared/Signals/Signal{Subjects}.cs index 97f2f5cd..cc4721b0 100644 --- a/src/Primitives.Shared/Signals/Signal{Subjects}.cs +++ b/src/Primitives.Shared/Signals/Signal{Subjects}.cs @@ -18,8 +18,7 @@ public static partial class Signal [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = - "The type parameter defines the element type for this Rx-style factory and cannot be inferred from the arguments.")] + Justification = "The element type cannot be inferred from the arguments.")] public static ScheduledSignal Scheduled(ISequencer scheduler) => new(scheduler); diff --git a/src/Primitives.Shared/Signals/TaskSignal.cs b/src/Primitives.Shared/Signals/TaskSignal.cs index 3fa21223..7f30e67b 100644 --- a/src/Primitives.Shared/Signals/TaskSignal.cs +++ b/src/Primitives.Shared/Signals/TaskSignal.cs @@ -13,41 +13,35 @@ namespace ReactiveUI.Primitives.Signals; /// Provides factory methods for creating task-backed signals. public static class TaskSignal { - /// Creates the specified source. + /// Creates a task-backed signal whose source the factory builds from the signal itself. /// The type of the result. - /// The observable factory. - /// - /// An AsyncObservable. - /// - /// observableFactory. + /// Builds the source, receiving the signal it will belong to. + /// A task-backed signal that notifies on the current thread and owns its own cancellation source. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal Create( Func, IObservable> observableFactory) => Instance(observableFactory, null, null); - /// Creates the specified source. + /// Creates a task-backed signal that notifies on the supplied sequencer. /// The type of the result. - /// The observable factory. - /// The scheduler. - /// - /// An AsyncObservable. - /// - /// observableFactory. + /// Builds the source, receiving the signal it will belong to. + /// The sequencer notifications are delivered on, or for the current thread. + /// A task-backed signal that owns its own cancellation source. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal Create( Func, IObservable> observableFactory, ISequencer? scheduler) => Instance(observableFactory, scheduler, null); - /// Creates the specified source. + /// Creates a task-backed signal that notifies on the supplied sequencer and cancels through the supplied source. /// The type of the result. - /// The observable factory. - /// The scheduler. - /// The cancellation token source. - /// - /// An AsyncObservable. - /// - /// observableFactory. + /// Builds the source, receiving the signal it will belong to. + /// The sequencer notifications are delivered on, or for the current thread. + /// The cancellation source to observe, or to own a new one. + /// A task-backed signal. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ITaskSignal Create( Func, IObservable> observableFactory, @@ -55,12 +49,12 @@ public static ITaskSignal Create( CancellationTokenSource? cancellationTokenSource) => Instance(observableFactory, scheduler, cancellationTokenSource); - /// Executes the Instance operation. - /// The TResult type. - /// The observableFactory value. - /// The scheduler value. - /// The cancellationTokenSource value. - /// The result. + /// Validates the factory and builds the signal the public overloads return. + /// The result type. + /// Builds the source from the signal. + /// The sequencer notifications are delivered on. + /// The cancellation source to observe. + /// The built signal. private static TaskSignal Instance( Func, IObservable> observableFactory, ISequencer? scheduler, diff --git a/src/Primitives.Shared/Signals/TaskSignal{T}.cs b/src/Primitives.Shared/Signals/TaskSignal{T}.cs index 0d72424d..9e5d77f3 100644 --- a/src/Primitives.Shared/Signals/TaskSignal{T}.cs +++ b/src/Primitives.Shared/Signals/TaskSignal{T}.cs @@ -11,60 +11,47 @@ namespace ReactiveUI.Primitives.Signals; #endif /// A task-backed signal of values. -/// The object that provides notification information. +/// The value type. internal sealed class TaskSignal : ITaskSignal { - /// Stores state for the signal implementation. + /// The sequencer subscriptions are observed on. private readonly ISequencer _sequencer; - /// Executes the new operation. - /// The result. + /// The subscriptions and cancellation registrations released on disposal. private readonly MultipleDisposable _cleanUp = []; /// Initializes a new instance of the class. /// The sequencer. - /// The cancellation token source. - /// - /// Private, so the factory can only ever see a signal that is already built. See . - /// + /// The cancellation token source, or to own a new one. private TaskSignal(ISequencer? sequencer, CancellationTokenSource? cancellationTokenSource) { CancellationTokenSource = cancellationTokenSource ?? new(); _sequencer = sequencer ?? CurrentThreadSequencer.Instance; } - /// Gets or sets the source. - /// - /// The source. - /// + /// Gets or sets the sequence subscribers are forwarded to. public IObservable? Source { get; set; } - /// Gets the cancellation token source. - /// - /// The cancellation token source. - /// + /// Gets the cancellation source that cancels the backing task. public CancellationTokenSource? CancellationTokenSource { get; } - /// Gets a value indicating whether this instance is cancellation requested. - /// - /// true if this instance is cancellation requested; otherwise, false. - /// + /// Gets a value indicating whether cancellation has been requested. public bool IsCancellationRequested => CancellationTokenSource?.IsCancellationRequested == true; - /// Gets a value indicating whether gets a value that indicates whether the object is disposed. + /// Gets a value indicating whether the signal has been disposed. public bool IsDisposed => _cleanUp.IsDisposed; - /// Gets the operation canceled. - /// The observer. + /// Pushes an to the observer when cancellation is requested. + /// The observer notified on cancellation. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void GetOperationCanceled(IObserver observer) => CancellationTokenSource?.Token .UnsafeRegister(static o => ((IObserver)o!).OnNext(new OperationCanceledException()), observer) .DisposeWith(_cleanUp); - /// Subscribes the specified observer. - /// The observer. - /// A Disposable. + /// Subscribes the observer to the source, observing on the sequencer unless it is the immediate one. + /// The observer to subscribe. + /// A disposable that ends the subscription. public IDisposable Subscribe(IObserver observer) { var subscription = ReferenceEquals(_sequencer, Sequencer.Immediate) @@ -74,19 +61,18 @@ public IDisposable Subscribe(IObserver observer) return subscription.DisposeWith(_cleanUp); } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Cancels the backing task and releases the subscriptions. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => Dispose(true); /// Creates a task-backed signal whose source the supplied factory builds. - /// The observable factory. - /// The sequencer. - /// The cancellation token source. + /// Builds the source, receiving the signal it will belong to. + /// The sequencer subscriptions are observed on. + /// The cancellation source to observe. /// The created signal. /// - /// The factory is handed the signal, so the signal has to be whole before it runs. A factory is - /// caller-supplied code that may subscribe to, dispose, or stash the signal the moment it receives - /// it; from a constructor it would be doing that to an object the runtime had not finished building. + /// The factory receives the signal and may subscribe to, dispose, or store it the moment it is called, so + /// construction finishes before the factory runs rather than calling it from the constructor. /// internal static TaskSignal Create( Func, IObservable> observableFactory, @@ -100,8 +86,8 @@ internal static TaskSignal Create( return signal; } - /// Releases unmanaged and - optionally - managed resources. - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// Cancels the token source and releases the subscriptions when disposing. + /// true to release managed resources; otherwise, false. private void Dispose(bool disposing) { if (_cleanUp.IsDisposed || !disposing) diff --git a/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs b/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs index 8015a44c..9eedb81e 100644 --- a/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs +++ b/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs @@ -11,11 +11,9 @@ namespace ReactiveUI.Primitives.Signals; #endif /// -/// Shared completion plumbing for task-returning terminals with optional cancellation: owns the task source, -/// the source subscription, and the cancellation registration. Callers wire their observer callbacks to -/// / and hand the subscription to , which registers -/// for cancellation only while the task is still pending — synchronous sources never pay for a registration, -/// and registering after the subscription write publishes it to the thread that runs the cancellation callback. +/// Owns the task source, the source subscription and the cancellation registration for task-returning terminals. +/// Callers wire their observer callbacks to / and hand the subscription to +/// , which registers for cancellation only while the task is pending. /// /// The task result type. internal sealed class TaskTerminalCompletion @@ -56,15 +54,13 @@ internal void Fail(Exception error) internal void FailEmpty() => Fail(new InvalidOperationException("The source completed without producing a value.")); - /// - /// Adopts the subscription created after the observer callbacks were wired, then registers for cancellation - /// only when the task is still pending. - /// + /// Adopts the source subscription and registers for cancellation only while the task is pending. /// The source subscription. /// The token that cancels the task and disposes the subscription. /// The terminal task. internal Task Attach(IDisposable subscription, CancellationToken cancellationToken) { + // Writing the subscription before registering publishes it to the thread that runs the cancellation callback. _subscription = subscription; if (_completion.Task.IsCompleted) { @@ -79,9 +75,8 @@ internal Task Attach(IDisposable subscription, CancellationToken cancellation static state => ((TaskTerminalCompletion)state!).Cancel(), this); - // The source may have completed while the registration was being created; the observer callbacks - // saw a default registration then, so release the real one here (without waiting on an in-flight - // callback, whose effects are already race-safe). + // A source that completed while the registration was being created saw a default registration in the + // observer callbacks, so release the real one here. if (_completion.Task.IsCompleted) { _ = _cancellationRegistration.Unregister(); diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs index f0c51133..4e644a8e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs @@ -9,9 +9,9 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// /// Asynchronous mutual-exclusion primitive that serializes critical sections in the async pipeline. -/// Uncontended acquire is a pure (no -/// touch); the contended path waits on a signal-only semaphore and retries -/// the CAS after each signal. Same-thread reentry is granted via the owner-thread-id and a recursion counter. +/// Each acquire hands back a that releases the gate when disposed. Ownership is keyed +/// on the managed thread id, so a nested acquire on the holding thread is granted immediately and reentry +/// is recognised only while the caller stays on the thread that took the gate. /// [System.Diagnostics.DebuggerDisplay("AsyncSerialGate: OwnerThreadId = {_ownerThreadId}, Waiters = {_waiters}, RecursionDepth = {_recursionDepth}")] public sealed class AsyncSerialGate : IDisposable @@ -31,9 +31,7 @@ public sealed class AsyncSerialGate : IDisposable /// Disposal latch; non-zero once this instance has been disposed. private int _disposedValue; - /// Gets the number of awaiters currently parked on the slow path. Exposed for - /// deterministic contention tests so they can spin-wait until a contender has entered - /// before tripping the release. + /// Gets the number of awaiters parked on the slow path. internal int WaitersCount => Volatile.Read(ref _waiters); /// Asynchronously acquires the gate, returning a that releases it on disposal. @@ -44,14 +42,17 @@ public ValueTask EnterAsync() => EnterAsync(CancellationToken.None); /// Asynchronously acquires the gate, returning a that releases it on disposal. - /// The cancellation token. + /// Observed only while waiting for a contended gate; an uncontended acquire + /// never checks it. /// A that completes when the gate has been acquired. + /// Thrown when the token is cancelled before the gate is + /// acquired. [DebuggerStepThrough] public ValueTask EnterAsync(CancellationToken cancellationToken) { var currentThreadId = Environment.CurrentManagedThreadId; - // Same-thread reentry: bump depth, no synchronization needed (we already own it). + // Same-thread reentry: the calling thread owns the gate, so bumping depth needs no synchronization. if (Volatile.Read(ref _ownerThreadId) == currentThreadId) { _recursionDepth++; @@ -92,9 +93,8 @@ internal void Exit() } /// - /// Signals one parked waiter if any are present. An extra signal observed across the - /// read / race lands harmlessly in - /// the semaphore count and is consumed by the next waiter that arrives. + /// Signals one parked waiter if any are present. A signal released after the last waiter has left + /// lands in the semaphore count and is consumed by the next waiter to arrive. /// private void WakeNextWaiter() { @@ -132,7 +132,7 @@ private async ValueTask WaitForEntryAsync(CancellationToken cancellationT } } - /// Releases a previously acquired when disposed. + /// Holds one acquisition of an and releases it on disposal. [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : IDisposable { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs index 2dcb589a..b3611bb8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs @@ -12,7 +12,8 @@ public sealed class BackgroundJobSignal : IObservableAsync /// Initializes a new instance of the class. /// The job to execute for each subscription. /// A value indicating whether the job starts synchronously on subscribe. - /// The scheduler used to start the job asynchronously. + /// The scheduler that starts the job, or to start it on + /// the thread pool after a yield. public BackgroundJobSignal( Func, CancellationToken, ValueTask> job, bool startSynchronously, @@ -31,7 +32,7 @@ public BackgroundJobSignal( /// Gets a value indicating whether the job starts synchronously on subscribe. private bool StartSynchronously { get; } - /// Gets the scheduler used to start the job asynchronously. + /// Gets the scheduler that starts the job asynchronously. private TaskScheduler? TaskScheduler { get; } /// @@ -66,7 +67,7 @@ private async ValueTask ExecuteAfterYieldAsync(IObserverAsync observer, Cance /// Starts the job through the configured task scheduler. /// The observer receiving job notifications. - /// The scheduler used to run the job. + /// The scheduler that runs the job. /// The cancellation token for the job. /// A task representing the job. private async ValueTask ExecuteOnSchedulerAsync( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs index d9cdcbcc..be99c208 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs @@ -4,7 +4,8 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// An witness that routes notifications through user-supplied asynchronous callbacks. +/// A witness that routes notifications through caller-supplied asynchronous callbacks. A callback left +/// unset reports resumable errors and terminal failures to the unhandled exception handler. /// The type of the elements received by the witness. /// The asynchronous function invoked for each element. /// An optional asynchronous function invoked when a resumable error occurs. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs index ee2bef82..cdba6c72 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs @@ -53,14 +53,17 @@ public FlatMapCoordinator(IObserverAsync observer) /// Gets or sets a value indicating whether this coordinator has been disposed. private bool Disposed { get; set; } - /// Sets the outer observer. - /// The outer observer. - /// A task representing the asynchronous assignment. + /// Takes ownership of the outer subscription, disposing it immediately when this coordinator + /// has finished. Callable once per coordinator. + /// The outer subscription to own. + /// A task that completes once the subscription has been stored or disposed. + /// Thrown when an outer subscription is set twice. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SetOuterObserverAsync(IAsyncDisposable observer) => OuterObserver.SetDisposableAsync(observer); - /// Links subscribe-time cancellation into the coordinator lifecycle. + /// Cancels this coordinator's lifetime when is cancelled. A token that + /// cannot be cancelled is ignored; a token cancelled at call time cancels the lifetime immediately. /// The subscribe-time cancellation token. public void LinkExternalCancellation(CancellationToken external) { @@ -84,9 +87,10 @@ public void LinkExternalCancellation(CancellationToken external) [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask DisposeAsync() => FinishAsync(null); - /// Subscribes and tracks an inner sequence. + /// Subscribes to an inner sequence and counts it as active until it completes. A failure raised while + /// subscribing finishes the whole sequence with that failure; a call after teardown is a no-op. /// The inner sequence. - /// A task representing the asynchronous subscription. + /// A task that completes once the inner sequence has been subscribed. public async ValueTask SubscribeInnerAsync(IObservableAsync inner) { lock (_gate) @@ -113,9 +117,10 @@ public async ValueTask SubscribeInnerAsync(IObservableAsync inner) } } - /// Forwards an inner value to the downstream observer. + /// Forwards an inner value downstream, serialized against the other inner sequences so the observer + /// is never entered concurrently. /// The value. - /// A task representing the asynchronous notification. + /// A task that completes once the observer has accepted the value. public async ValueTask RelayNextAsync(TResult value) { using (await ObserverGate.EnterAsync(DisposeToken).ConfigureAwait(false)) @@ -124,9 +129,10 @@ public async ValueTask RelayNextAsync(TResult value) } } - /// Forwards a non-terminal error to the downstream observer. + /// Forwards a non-terminal error downstream under the same serialization as values, leaving the + /// sequence running. /// The error. - /// A task representing the asynchronous notification. + /// A task that completes once the observer has accepted the error. public async ValueTask RelayErrorAsync(Exception error) { using (await ObserverGate.EnterAsync(DisposeToken).ConfigureAwait(false)) @@ -135,9 +141,10 @@ public async ValueTask RelayErrorAsync(Exception error) } } - /// Handles outer source completion. + /// Records outer completion, finishing the sequence when the result is a failure or no inner + /// sequence is active, and otherwise waiting for the active inner sequences. /// The completion result. - /// A task representing the asynchronous completion handling. + /// A task that completes once the sequence has finished, or immediately when it continues. public ValueTask CompleteOuterAsync(Result result) { bool shouldComplete; @@ -150,9 +157,10 @@ public ValueTask CompleteOuterAsync(Result result) return shouldComplete ? FinishAsync(result) : default; } - /// Handles inner source completion. + /// Records one inner sequence finishing, completing the sequence when the result is a failure or + /// when the outer source has completed and no inner sequence remains. /// The completion result. - /// A task representing the asynchronous completion handling. + /// A task that completes once the sequence has finished, or immediately when it continues. public ValueTask CompleteInnerAsync(Result result) { bool shouldComplete; @@ -169,9 +177,11 @@ public ValueTask CompleteInnerAsync(Result result) return shouldComplete ? FinishAsync(result) : default; } - /// Finishes the flat-map sequence and disposes tracked subscriptions. - /// The optional result to forward. - /// A task representing the asynchronous teardown. + /// Completes the downstream observer and disposes every tracked subscription. Idempotent; a failure + /// handed in after teardown goes to the unhandled exception handler. + /// The result to forward, or to tear down without completing + /// the observer. + /// A task that completes once teardown has finished. public async ValueTask FinishAsync(Result? result) { lock (_gate) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs index 39242aac..4bb02c4e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs @@ -5,9 +5,8 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Base observer that forwards every notification to a downstream observer. Every notification is already -/// implemented here, so nothing is left for a derived type to supply: this is a base class, not a contract. -/// The protected constructor, rather than abstract, is what keeps it from being used on its own. +/// Base observer that forwards every notification unchanged to a downstream observer. A derived type +/// overrides only the notifications it needs to intercept and inherits pass-through behaviour for the rest. /// /// The observed element type. [System.Diagnostics.DebuggerDisplay("ForwardingWitnessAsync: Downstream = {Downstream}")] diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs index d74cc907..79580141 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs @@ -5,10 +5,9 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// An async-disposable that can be disposed from within its own in-flight notification. -/// Terminal sinks dispose themselves from inside the very OnNext/OnCompleted call that -/// produced their result. The normal path waits for in-flight -/// calls on other threads to drain before completing; once that notification's continuation has hopped threads the -/// wait would block on the call that is itself awaiting the dispose. This entry point skips that self-join. +/// A terminal sink disposes itself from inside the OnNext/OnCompleted call that produced +/// its result, which cannot serve: that path waits for in-flight calls +/// to drain, and the notification awaiting the dispose is one of them. This entry point skips the self-join. public interface IReentrantAsyncDisposable { /// Disposes from within the object's own in-flight notification, skipping the in-flight-call wait. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs index 2cefff2c..01b0b253 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs @@ -4,13 +4,15 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// An observable that emits incrementing ticks at a fixed interval. +/// An observable that emits incrementing ticks at a fixed interval. Ticks are numbered from one, each +/// emitted after a further period has elapsed, and continue until the subscription is disposed. [System.Diagnostics.DebuggerDisplay("IntervalSignal: Period = {Period}, TimeProvider = {TimeProvider}")] public sealed class IntervalSignal : IObservableAsync { /// Initializes a new instance of the class. /// The delay between ticks. - /// The time provider used for custom scheduling. + /// The time provider that schedules the ticks, or for the + /// system clock. public IntervalSignal(TimeSpan period, TimeProvider? timeProvider) { Period = period; diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs index 70a44769..24b95d8b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs @@ -11,7 +11,8 @@ public sealed class IntervalSubscription : TaskSignalSubscription /// Initializes a new instance of the class. /// The observer receiving ticks. /// The delay between ticks. - /// The time provider used for custom scheduling. + /// The time provider that schedules the ticks, or for the + /// system clock. public IntervalSubscription(IObserverAsync observer, TimeSpan period, TimeProvider? timeProvider) : base(observer) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs index f7cf4970..63f4e692 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs @@ -47,16 +47,16 @@ public LeadSubscription( /// Gets the subscription cancellation source. private CancellationTokenSource Cancellation { get; } - /// Gets the reentrancy marker used to avoid self-joining the driver task. + /// Gets the flag marking the driver task, so disposal called from inside it skips awaiting it. private AsyncLocal Reentrant { get; } = new(); /// Gets or sets the driver task. private Task Pipeline { get; set; } - /// Gets or sets a value indicating whether disposal has already run. + /// Gets or sets a value indicating whether disposal has run. private bool Disposed { get; set; } - /// Starts the lead pipeline. + /// Starts the pipeline that emits the leading values and then subscribes to the source. public void Start() => Pipeline = RunAsync(); /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs index 5e8578fa..0dba9c7d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs @@ -2,7 +2,6 @@ // 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.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading.Tasks.Sources; @@ -11,18 +10,13 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Poolable used by DelayAsync for the non-System -/// code path. Replaces the per-call -/// + + -/// allocation chain with a rented-then-returned -/// instance. The wrapped is the standard -/// poolable async-primitive shape from System.Threading.Tasks.Sources. +/// Poolable backing DelayAsync for non-System +/// instances, so a delay costs no per-call allocation. /// /// -/// Completion is claimed by whichever of the timer callback or the cancellation registration -/// fires first, using an on a state -/// flag. The loser is a no-op. After the caller awaits the returned , the -/// instance is reset and pushed back to the pool inside . +/// Whichever of the timer callback and the cancellation registration fires first claims completion +/// through an on a state flag; the +/// loser is a no-op. /// [System.Diagnostics.DebuggerDisplay("PooledDelaySource: Completed = {_completed}, Timer = {_timer}")] public sealed class PooledDelaySource : IValueTaskSource @@ -34,12 +28,9 @@ public sealed class PooledDelaySource : IValueTaskSource private const int StateClaimed = 1; /// - /// Per-thread cached instance. A thread-static slot has zero per-rent / per-return allocation - /// (a allocates a Node wrapper, which - /// dominated allocation in the first cut of this pool). Single-slot caching is sufficient - /// because the operators that consume DelayAsync serialise their per-instance work - /// behind a gate, so a single thread holds at most one in-flight delay per operator at a time. - /// Concurrent delays from different threads each get their own cached slot. + /// Per-thread cached instance; one slot per thread suffices because the operators that consume + /// DelayAsync serialise their work behind a gate, so a thread holds at most one + /// in-flight delay per operator. /// [ThreadStatic] private static PooledDelaySource? _threadCached; @@ -93,9 +84,8 @@ public ValueTask BeginAsync(TimeSpan delay, TimeProvider timeProvider, Cancellat return new(this, _core.Version); } - // CreateTimer may invoke the callback synchronously (the immediate-fire pattern used by - // some test / benchmark providers); in that case _completed flips to Claimed before this - // call returns. + // CreateTimer may invoke the callback synchronously, flipping _completed to Claimed before + // this call returns. _timer = timeProvider.CreateTimer( static state => ((PooledDelaySource)state!).OnTimerFired(), this, @@ -104,7 +94,7 @@ public ValueTask BeginAsync(TimeSpan delay, TimeProvider timeProvider, Cancellat if (Volatile.Read(ref _completed) == StateClaimed) { - // Sync-fire fast path: no cancellation registration needed; the source is already done. + // Sync-fire fast path: the source is complete, so no cancellation registration is needed. return new(this, _core.Version); } @@ -144,12 +134,7 @@ public void GetResult(short token) } } - /// - /// 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 . - /// + /// Completes the delay successfully when the timer's dueTime elapses, unless cancellation claimed it first. [ExcludeFromCodeCoverage] private void OnTimerFired() { @@ -161,7 +146,7 @@ private void OnTimerFired() _core.SetResult(true); } - /// Callback invoked when the caller's cancellation token transitions to cancelled. Same race-only loser branch as . + /// Faults the delay with when the caller's token fires, unless the timer claimed it first. /// The cancellation token that fired. [ExcludeFromCodeCoverage] private void OnCancelled(CancellationToken cancellationToken) @@ -184,8 +169,7 @@ private void ReturnToPool() _completed = StateOpen; _core.Reset(); - // Only one instance cached per thread; drop the rest for the GC. Capacity-of-one is the - // sweet spot for these operators — they hold at most one in-flight delay per gate. + // One instance cached per thread; any extra instances are dropped for the GC. _threadCached ??= this; } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SingleElementWitness.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SingleElementWitness.cs index 1e15ed26..5bb25f3a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SingleElementWitness.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SingleElementWitness.cs @@ -5,17 +5,15 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Shared observer used by both SingleAsync and SingleOrDefaultAsync. The two operator -/// surfaces previously held near-identical observer classes; the only behavioural difference is -/// whether an empty sequence throws or returns a caller-supplied default. That difference is now a -/// single flag on this type, so the OnNext / OnErrorResume / OnCompleted bodies live in one place. +/// Observer that resolves the one element of the source sequence matching a predicate, faulting the +/// result with an as soon as a second match arrives. /// /// The type of elements in the source sequence. /// An optional predicate to filter elements; null matches all elements. /// -/// When true (the SingleAsync shape), an empty sequence completes the result task with -/// an . When false (the SingleOrDefaultAsync -/// shape), an empty sequence resolves the result task with . +/// When true, an empty sequence faults the result task with an +/// ; when false, it resolves the result task with +/// . /// /// The value to return on empty when is false. /// A cancellation token for the operation. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSignal{TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSignal{TResult}.cs index c88fdce9..57cfce3e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSignal{TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSignal{TResult}.cs @@ -11,7 +11,7 @@ public sealed class StartSignal : IObservableAsync { /// Initializes a new instance of the class. /// The function to invoke. - /// The optional scheduler used to invoke the function. + /// The optional scheduler that invokes the function; invokes it inline. public StartSignal(Func function, TaskScheduler? taskScheduler) { ArgumentExceptionHelper.ThrowIfNull(function); @@ -23,7 +23,7 @@ public StartSignal(Func function, TaskScheduler? taskScheduler) /// Gets the function to invoke. private Func Function { get; } - /// Gets the optional scheduler used to invoke the function. + /// Gets the optional scheduler that invokes the function. private TaskScheduler? TaskScheduler { get; } /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs index 51bc4962..a784cc14 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs @@ -12,7 +12,7 @@ public sealed class StartSubscription : TaskSignalSubscription /// Initializes a new instance of the class. /// The observer receiving the produced value. /// The function to invoke. - /// The optional scheduler used to invoke the function. + /// The optional scheduler that invokes the function; invokes it inline. public StartSubscription( IObserverAsync observer, Func function, @@ -28,7 +28,7 @@ public StartSubscription( /// Gets the function to invoke. private Func Function { get; } - /// Gets the optional scheduler used to invoke the function. + /// Gets the optional scheduler that invokes the function. private TaskScheduler? TaskScheduler { get; } /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest10Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest10Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,TResult}.cs index 22f3b7f0..01d7b72c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest10Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest10Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,TResult}.cs @@ -170,10 +170,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source10.SubscribeAsync(new SyncLatestWitness(this, Source10Bit, value => Value10 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest11Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest11Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,TResult}.cs index 1d4ee742..3e704202 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest11Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest11Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,TResult}.cs @@ -182,10 +182,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source11.SubscribeAsync(new SyncLatestWitness(this, Source11Bit, value => Value11 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest12Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest12Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,TResult}.cs index e3744dd8..6040e7c8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest12Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest12Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,TResult}.cs @@ -207,10 +207,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source12.SubscribeAsync(new SyncLatestWitness(this, Source12Bit, value => Value12 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest13Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest13Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,TResult}.cs index 66147ca2..f8632737 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest13Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest13Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,TResult}.cs @@ -220,10 +220,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source13.SubscribeAsync(new SyncLatestWitness(this, Source13Bit, value => Value13 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest14Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest14Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,TResult}.cs index b7cd2805..fba0dca6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest14Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest14Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,TResult}.cs @@ -233,10 +233,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source14.SubscribeAsync(new SyncLatestWitness(this, Source14Bit, value => Value14 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest15Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest15Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,TResult}.cs index 5d490766..82fef33c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest15Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest15Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,TResult}.cs @@ -246,10 +246,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source15.SubscribeAsync(new SyncLatestWitness(this, Source15Bit, value => Value15 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest16Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,T16,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest16Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,T16,TResult}.cs index 5cf7d9c1..2cd9ca0d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest16Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,T16,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest16Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,T10,T11,T12,T13,T14,T15,T16,TResult}.cs @@ -258,10 +258,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source16.SubscribeAsync(new SyncLatestWitness(this, Source16Bit, value => Value16 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest2Coordinator{T1,T2,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest2Coordinator{T1,T2,TResult}.cs index b7f999ed..decd0dd9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest2Coordinator{T1,T2,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest2Coordinator{T1,T2,TResult}.cs @@ -71,10 +71,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source2.SubscribeAsync(new SyncLatestWitness(this, Source2Bit, value => Value2 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest3Coordinator{T1,T2,T3,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest3Coordinator{T1,T2,T3,TResult}.cs index 44faa290..d7396651 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest3Coordinator{T1,T2,T3,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest3Coordinator{T1,T2,T3,TResult}.cs @@ -83,10 +83,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source3.SubscribeAsync(new SyncLatestWitness(this, Source3Bit, value => Value3 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2, T3 V3) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest4Coordinator{T1,T2,T3,T4,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest4Coordinator{T1,T2,T3,T4,TResult}.cs index 02e997b0..95a8d13f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest4Coordinator{T1,T2,T3,T4,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest4Coordinator{T1,T2,T3,T4,TResult}.cs @@ -95,10 +95,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source4.SubscribeAsync(new SyncLatestWitness(this, Source4Bit, value => Value4 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2, T3 V3, T4 V4) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest5Coordinator{T1,T2,T3,T4,T5,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest5Coordinator{T1,T2,T3,T4,T5,TResult}.cs index 149a2683..332ee78e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest5Coordinator{T1,T2,T3,T4,T5,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest5Coordinator{T1,T2,T3,T4,T5,TResult}.cs @@ -107,10 +107,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source5.SubscribeAsync(new SyncLatestWitness(this, Source5Bit, value => Value5 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2, T3 V3, T4 V4, T5 V5) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest6Coordinator{T1,T2,T3,T4,T5,T6,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest6Coordinator{T1,T2,T3,T4,T5,T6,TResult}.cs index 93d7e9af..7b23638e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest6Coordinator{T1,T2,T3,T4,T5,T6,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest6Coordinator{T1,T2,T3,T4,T5,T6,TResult}.cs @@ -119,10 +119,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source6.SubscribeAsync(new SyncLatestWitness(this, Source6Bit, value => Value6 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2, T3 V3, T4 V4, T5 V5, T6 V6) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest7Coordinator{T1,T2,T3,T4,T5,T6,T7,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest7Coordinator{T1,T2,T3,T4,T5,T6,T7,TResult}.cs index 57eb774a..d96c60ae 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest7Coordinator{T1,T2,T3,T4,T5,T6,T7,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest7Coordinator{T1,T2,T3,T4,T5,T6,T7,TResult}.cs @@ -131,10 +131,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source7.SubscribeAsync(new SyncLatestWitness(this, Source7Bit, value => Value7 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2, T3 V3, T4 V4, T5 V5, T6 V6, T7 V7) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest8Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest8Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,TResult}.cs index c3d8d8ed..e45a73d6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest8Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest8Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,TResult}.cs @@ -143,10 +143,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source8.SubscribeAsync(new SyncLatestWitness(this, Source8Bit, value => Value8 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2, T3 V3, T4 V4, T5 V5, T6 V6, T7 V7, T8 V8) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest9Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest9Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,TResult}.cs index ce8bb04b..4606a3b3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest9Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatest9Coordinator{T1,T2,T3,T4,T5,T6,T7,T8,T9,TResult}.cs @@ -156,10 +156,7 @@ protected override ValueTask SubscribeAtAsync(int index, Cance _ => Sources.Source9.SubscribeAsync(new SyncLatestWitness(this, Source9Bit, value => Value9 = new(value)), cancellationToken) }; - /// - /// Reads every source's latest value into a single snapshot. Returns - /// until every source has produced at least one value. - /// + /// Reads every source's latest value into a single snapshot. /// When the method returns , the snapshot. /// when every source has produced a value; otherwise . private bool TryReadValues(out (T1 V1, T2 V2, T3 V3, T4 V4, T5 V5, T6 V6, T7 V7, T8 V8, T9 V9) values) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs index 83569bbf..f6d753ca 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs @@ -5,11 +5,9 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Shared scaffolding for the arity-specific CombineLatestN subscription types. Each -/// per-arity SyncLatestCoordinator derives from this class so the otherwise-identical -/// wiring (gate / dispose CTS / external link), -/// the values-lock, the source-subscribe loop, the error-resume forwarder, and -/// live here once instead of repeated 15× across CombineLatest2..16. +/// Base class for the arity-specific CombineLatestN coordinators, owning the +/// wiring, the values-lock, the source-subscribe loop, +/// the error-resume forwarder and disposal. /// /// The downstream element type. [System.Diagnostics.DebuggerDisplay("SyncLatestCoordinatorBase: SourceCount = {Lifecycle.Subscriptions.Length}, HasDisposed = {Lifecycle.HasDisposed}")] @@ -24,9 +22,7 @@ protected SyncLatestCoordinatorBase(IObserverAsync observer, int source /// Gets the shared subscription lifecycle (gate / dispose CTS / external link / forwarders). internal SyncLatestLifecycle Lifecycle { get; } - /// Gets the lock protecting per-arity latest-values caches. Internal so the shared - /// can lock on it without deriving - /// from this base. + /// Gets the lock guarding the derived coordinator's latest-value slots. internal Lock ValuesLock { get; } = new(); /// Subscribes to every source observable via . @@ -49,9 +45,9 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } - /// Relays an upstream error to the downstream observer; thin shim with the (error, ct) signature that expects. + /// Relays an upstream error to the downstream observer. /// The error to forward. - /// Ignored — the lifecycle uses its own dispose token. + /// Ignored; the lifecycle uses its own dispose token. /// A ValueTask representing the asynchronous forward. internal ValueTask RelaySourceErrorAsync(Exception error, CancellationToken cancellationToken) { @@ -60,18 +56,14 @@ internal ValueTask RelaySourceErrorAsync(Exception error, CancellationToken canc } /// - /// Reads the per-arity Optional slots, projects them through the selector when every source - /// has produced a value, and forwards the result downstream via the lifecycle. Invoked by - /// after a per-source OnNext has - /// landed under . + /// Projects the latest-value slots through the selector and forwards the result downstream, doing + /// nothing until every source has produced a value. Called after a per-source OnNext has landed + /// under . /// /// A ValueTask representing the asynchronous emit. internal abstract ValueTask EmitLatestAsync(); - /// - /// Subscribes to a single source by 0-based index. Implemented per-arity by the derived - /// SyncLatestCoordinator with a typed switch dispatch over the bundled sources. - /// + /// Subscribes to a single source by 0-based index, dispatching to the derived coordinator's typed slot. /// 0-based source index. /// A token to cancel the subscription. /// The subscription disposable for the source at . diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs index c5c2c0f1..7138fc4f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs @@ -97,7 +97,7 @@ public async ValueTask SubscribeSourcesAsync(CancellationToken cancellationToken [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask DisposeAsync() => FinishAsync(null); - /// Handles OnNext from a source. + /// Records the source's latest value and emits the projected snapshot once every source has produced one. /// The source index. /// The value. /// The cancellation token. @@ -143,7 +143,7 @@ internal async ValueTask OnNextAsync(int index, TSource indexValue, Cancellation } } - /// Handles OnErrorResume from a source. + /// Forwards a non-terminal source error downstream under the gate. /// The error. /// The cancellation token. /// A value task representing the operation. @@ -160,7 +160,7 @@ internal async ValueTask OnErrorResumeAsync(Exception error, CancellationToken c } } - /// Handles OnCompleted from a source. + /// Completes the combined sequence when the source failed, completed without a value, or was the last to complete. /// The source index. /// The result. /// A value task representing the operation. @@ -187,8 +187,8 @@ internal ValueTask OnCompletedAsync(int index, Result result) return shouldComplete ? FinishAsync(Result.Success) : default; } - /// Completes the subscription. The gate and dispose CTS are always released in the finally block. - /// The result. + /// Completes the subscription once, disposing every source subscription, the gate and the dispose token. + /// The completion result, or when disposing without signalling. /// A value task representing the operation. internal async ValueTask FinishAsync(Result? result) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs index 8dce1f40..81bbcb87 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs @@ -5,20 +5,16 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Per-source used by every CombineLatestN subscription. The -/// per-arity class previously declared N hand-rolled OnNextN / OnCompletedN method -/// pairs whose bodies differed only in which Optional<TN> field they wrote and which -/// completion bit they passed to the lifecycle. Pre-building N of these witnesses at subscription -/// time keeps the typing exact and eliminates the per-source method declarations from the per-arity -/// files. The closure cost (one delegate per source for the value-write) is paid once at subscribe -/// and not per emission; the actual per-emission cost is one indirect delegate invoke under the -/// values-lock. +/// Per-source for a CombineLatestN subscription: records each +/// value into the parent's typed slot under the values-lock, then asks the parent to emit. One +/// instance is built per source at subscribe time, so the per-emission cost is a single delegate +/// invoke. /// /// The element type of the upstream source this witness subscribes to. /// The downstream element type owned by the parent subscription. /// The parent subscription that owns the values-lock and lifecycle. /// The completion bitmask bit owned by this source (1 << index). -/// Stores the freshly-emitted value into the parent's typed _valN slot. +/// Stores the emitted value into the parent's typed slot for this source. public sealed class SyncLatestIndexedWitness( SyncLatestCoordinatorBase parent, int sourceBit, diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs index 13426390..c9ab8df4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs @@ -7,11 +7,9 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Shared subscription lifecycle for the arity-specific CombineLatestN operators (2..16) and -/// the enumerable variant. Each per-arity SyncLatestCoordinator composes one instance of -/// this class (has-a, not is-a) and forwards lifecycle / error / gating work into it, so the -/// previously-duplicated infrastructure (gate, dispose CTS, external-link registration, observer -/// fan-out, completion-bitmask handling) lives in one place. +/// Subscription lifecycle for the CombineLatestN operators (2..16) and the enumerable +/// variant: owns the serialization gate, the dispose cancellation source, the external-link +/// registration, the gated observer fan-out and the completion bitmask. /// /// The downstream element type. [System.Diagnostics.DebuggerDisplay("SyncLatestLifecycle: SourceCount = {Subscriptions.Length}, HasDisposed = {HasDisposed}")] @@ -62,9 +60,8 @@ public SyncLatestLifecycle(IObserverAsync observer, int sourceCount) public bool HasDisposed => DisposalHelper.HasDisposed(_disposed); /// - /// Links the original subscribe-time cancellation token into this subscription's dispose chain so - /// per-emission methods can use directly instead of allocating a - /// per-emission linked CTS. + /// Links the subscribe-time cancellation token into this subscription's dispose chain, so + /// alone covers both and no per-emission linked source is needed. /// /// The subscribe-time token. public void LinkExternalCancellation(CancellationToken external) @@ -177,9 +174,8 @@ public async ValueTask FinishAsync(Result? result) } finally { - // Always release the unmanaged-style primitives even if upstream DisposeAsync or - // OnCompletedAsync throws — otherwise a misbehaving downstream leaks the gate's - // SemaphoreSlim and the dispose CTS's wait handles. + // A throwing upstream DisposeAsync or OnCompletedAsync must not leak the gate's + // SemaphoreSlim or the dispose CTS's wait handles. #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER await _externalLinkRegistration.DisposeAsync().ConfigureAwait(false); #else diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs index 8337b317..5de76507 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs @@ -5,12 +5,9 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Shared subscription lifecycle for the TakeUntil family of operators (CancellationToken / Task / -/// raw-signal / async-observable / async-predicate variants). Each per-trigger Subscription -/// composes one instance of this class (has-a, not is-a) and forwards forward-on-next / -error / -/// -completed plus external-cancellation linking into it, so the previously-duplicated -/// infrastructure (gate, dispose CTS, external-link registration, gated observer fan-out) lives -/// in one place. +/// Subscription lifecycle for the TakeUntil family of operators (CancellationToken, Task, +/// raw-signal, async-observable and async-predicate triggers): owns the serialization gate, the +/// dispose cancellation source, the external-link registration and the gated observer fan-out. /// /// The downstream element type. [System.Diagnostics.DebuggerDisplay("TakeUntilLifecycle: Observer = {_observer}, DisposeRequested = {DisposeToken.IsCancellationRequested}")] @@ -40,9 +37,8 @@ public TakeUntilLifecycle(IObserverAsync observer) public CancellationToken DisposeToken { get; } /// - /// Links the original subscribe-time cancellation token into this subscription's dispose chain so - /// per-emission methods can use directly instead of allocating a - /// per-emission linked CTS. + /// Links the subscribe-time cancellation token into this subscription's dispose chain, so + /// alone covers both and no per-emission linked source is needed. /// /// The subscribe-time token. public void LinkExternalCancellation(CancellationToken external) @@ -97,12 +93,9 @@ public async ValueTask RelayCompletionAsync(Result result) } /// - /// Tears down the shared subscription state: cancels the dispose CTS, releases the external-link - /// registration, disposes the gate. The owning Subscription is responsible for disposing its - /// per-operator handles (source subscription, token callback, etc.) BEFORE or AFTER calling this - /// — the order is operator-specific. The release of unmanaged-style primitives is wrapped in - /// try/finally so a misbehaving downstream can't leak the gate's SemaphoreSlim or the CTS wait - /// handles. + /// Cancels the dispose token, releases the external-link registration and disposes the gate. The + /// owning subscription disposes its own per-operator handles (source subscription, token + /// callback) separately, in whichever order that operator requires. /// /// A ValueTask representing the asynchronous teardown. public async ValueTask DisposeAsync() diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs index ac7e6d06..2850efc3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs @@ -5,10 +5,8 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Shared implementation that forwards every source notification -/// straight into a instance. Used by every per-trigger -/// TakeUntil Subscription so the per-operator inner-class shells (which previously held identical -/// three-method forwarders) collapse into a single shared type. +/// that forwards every source notification straight into a +/// , which gates it on its way downstream. /// /// The downstream element type. /// The shared lifecycle owning the gate and forwarding logic. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs index ba971422..6a6f89d9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; [System.Diagnostics.DebuggerDisplay("TaskResultCompletionSource: IsCompleted = {_taskSource.Task.IsCompleted}")] public sealed class TaskResultCompletionSource(CancellationToken cancellationToken) { - /// The task completion source used to publish the terminal result. + /// The task completion source that publishes the terminal result. private readonly TaskCompletionSource _taskSource = new(TaskCreationOptions.RunContinuationsAsynchronously); /// The cancellation token that cancels the terminal wait. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultWitnessAsyncBase.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultWitnessAsyncBase.cs index 36cbd2d1..04df541f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultWitnessAsyncBase.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultWitnessAsyncBase.cs @@ -10,11 +10,11 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// Base class for witnesses that produce a single task-based result value when the observed sequence completes. /// The type of elements received from the observable sequence. /// The type of the result value produced by this witness. -/// A cancellation token used to cancel the waiting operation. +/// A token that cancels the wait for the result value. [System.Diagnostics.DebuggerDisplay("TaskResultWitnessAsyncBase: Completion = {_completion}")] public abstract class TaskResultWitnessAsyncBase(CancellationToken cancellationToken) : WitnessAsync { - /// The completion helper used to produce and cancel the observer's single result value. + /// The completion helper that produces and cancels the observer's single result value. private readonly TaskResultCompletionSource _completion = new(cancellationToken); /// Asynchronously waits for the observer to produce its result value. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs index 469b7580..bee366c6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs @@ -5,47 +5,37 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// -/// Represents an asynchronous subscription that can be cancelled and disposed, managing the lifecycle of an -/// observer and its associated operations. +/// A subscription that runs a cancellable asynchronous job feeding a single observer, and joins that +/// job on disposal. /// /// The type of the elements observed by the subscription. /// The observer that receives notifications for the subscription. Cannot be null. -/// This type provides a base for implementing cancellable, asynchronously disposable -/// subscriptions that coordinate observer notifications and resource cleanup. Disposal cancels any ongoing -/// operations and ensures that all resources are released before completion. Derived classes should implement the -/// core execution logic in . +/// Disposal cancels the running job and waits for it to finish before releasing resources; +/// derived classes supply the job body in . [System.Diagnostics.DebuggerDisplay("TaskSignalSubscription: Disposed = {_disposed}, Completed = {_tcs.Task.IsCompleted}")] public abstract class TaskSignalSubscription(IObserverAsync observer) : IAsyncDisposable { - /// The task completion source used to signal when the subscription's asynchronous operation has finished. + /// The task completion source that signals when the subscription's job has finished. private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - /// The cancellation token source used to cancel the subscription's asynchronous operation upon disposal. + /// The cancellation token source that cancels the subscription's job on disposal. private readonly CancellationTokenSource _cts = new(); - /// Flows into the job's notification call chain (across any thread hops) so a reentrant - /// issued from inside the job — e.g. a downstream operator disposing the - /// subscription from within its own OnNextAsync — is recognised and skips the self-join on - /// that would otherwise deadlock. A thread-ID marker only catches the synchronous - /// same-thread case; once the notification continuation hops threads the ID no longer matches and the - /// dispose waits for a job that cannot complete until the dispose returns. + /// Flows through the job's notification chain across thread hops, so a reentrant + /// issued from inside the job skips the self-join on + /// that would otherwise deadlock. private readonly AsyncLocal _executing = new(); - /// Indicates whether disposal has already been initiated to prevent double-disposal. + /// Set on the first disposal so later calls are no-ops. private int _disposed; - /// Starts the operation synchronously using the current cancellation token. - /// This method initiates the asynchronous operation and does not wait for its completion. To - /// monitor progress or handle completion, use the asynchronous counterpart directly. The - /// returned by is converted to a - /// before being discarded so the fire-and-forget pattern stays compatible with CA2012. + /// Starts the subscription's job and returns without waiting for it to finish. public void Start() => _ = ExecuteAsync(_cts.Token).AsTask(); /// Asynchronously releases the resources used by the object and cancels any ongoing operations. /// A ValueTask that represents the asynchronous dispose operation. - /// Call this method to ensure that all resources are released and any pending operations - /// are cancelled before the object is discarded. Await the returned ValueTask to guarantee that disposal has - /// completed. + /// Joins the in-flight job before returning, except when called from inside that job's own + /// notification, where joining would deadlock. public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) @@ -84,7 +74,7 @@ internal static async ValueTask CompleteWithFailureAsync(IObserverAsync obser } /// Executes the subscription's core logic, handling exceptions by completing the observer with a failure result. - /// A cancellation token that can be used to cancel the operation. + /// A token that cancels the subscription's job. /// A representing the asynchronous operation. internal async ValueTask ExecuteAsync(CancellationToken cancellationToken) { @@ -105,7 +95,7 @@ internal async ValueTask ExecuteAsync(CancellationToken cancellationToken) /// When overridden in a derived class, executes the core subscription logic asynchronously. /// The observer that receives notifications. - /// A cancellation token that can be used to cancel the operation. + /// A token that cancels the subscription's job. /// A representing the asynchronous operation. protected abstract ValueTask ExecuteAsyncCore(IObserverAsync observer, CancellationToken cancellationToken); } diff --git a/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs index cdb2c66f..10726809 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs @@ -5,41 +5,31 @@ namespace ReactiveUI.Primitives.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. +/// Pure race-claim helpers shared by the async layer: a first-caller-wins sentinel transition, and a cancel call that +/// treats a concurrently disposed token source as a lost race rather than a failure. /// internal static class ConcurrencyRaceHelpers { /// - /// Atomically transitions from - /// to . Returns if this caller - /// won the race; if another caller had already claimed the state. + /// Atomically transitions from to + /// . /// /// 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. + /// when this caller won the claim; when another caller held it. /// internal 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. + /// Cancels , swallowing the a racing dispose raises. /// /// The cancellation token source to cancel. /// - /// if the cancellation completed; if the - /// source was already disposed. + /// when the cancellation completed; when a concurrent caller had + /// disposed the source first. /// internal static async ValueTask TryCancelAsync(CancellationTokenSource cts) { diff --git a/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs b/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs index abf674b7..5dde5678 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs @@ -12,13 +12,13 @@ namespace ReactiveUI.Primitives.Async; /// The exception that is thrown when multiple concurrent calls are made to observer methods that do not support /// concurrent execution. /// -/// This exception indicates that a call to OnNextAsync, OnErrorResumeAsync, or OnCompletedAsync was -/// attempted while a previous call to one of these methods is still pending. ObserverAsync{T} does not allow concurrent -/// invocations of these methods; callers should ensure that each call completes before initiating another. +/// serializes OnNextAsync, OnErrorResumeAsync and +/// OnCompletedAsync; this exception reports one of those calls arriving while another is in flight. Await each +/// call to completion before starting the next. [Serializable] public class ConcurrentWitnessCallsException : Exception { - /// The default error message describing the concurrent observer call violation. + /// The message used when no caller-supplied message is given. private const string DefaultMessage = $"Concurrent calls of {nameof(WitnessAsync<>)}.OnNextAsync, {nameof(WitnessAsync<>)}.OnErrorResumeAsync," + $" {nameof(WitnessAsync<>)}.OnCompletedAsync are not allowed. There is already a call pending"; diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs index 58c4bce0..f2ff57d1 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs @@ -9,14 +9,12 @@ namespace ReactiveUI.Primitives.Async; /// -/// Represents an asynchronous observable sequence that can be connected to a data source, allowing control over when -/// the subscription to the underlying resource is established. +/// An asynchronous observable that multicasts a cold source through a signal, leaving the caller to decide when the +/// source subscription is established. /// /// The type of elements produced by the observable sequence. -/// A connectable observable enables explicit control over the connection to the data source, which can -/// be useful for sharing a single subscription among multiple observers or for deferring the start of data emission -/// until explicitly connected. Implementations may vary in how connections are managed and whether multiple connections -/// are supported concurrently. +/// Subscribing attaches the observer to the signal without touching the source, so observers that subscribe +/// before all share the one source subscription it creates. [System.Diagnostics.DebuggerDisplay("ConnectableSignalAsync: State = {State}")] public sealed class ConnectableSignalAsync : IObservableAsync, IDisposable { @@ -30,27 +28,22 @@ public ConnectableSignalAsync(IObservableAsync source, ISignalAsync signal private ConnectableSignalAsyncState State { get; } /// - /// Asynchronously establishes a connection to the target resource and returns a disposable handle for managing the - /// connection's lifetime. + /// Subscribes the signal to the source, or returns the live connection when one exists. /// /// A cancellation token that can be used to cancel the asynchronous connection operation. - /// A value task that represents the asynchronous operation. The result contains an - /// that should be disposed to close the connection. - /// The returned must be disposed when the connection is no longer - /// needed to ensure proper resource cleanup. Multiple calls to this method may result in multiple independent - /// connections, depending on the implementation. + /// A handle whose disposal drops the source subscription, allowing a later call to reconnect. + /// This instance has been disposed, or + /// was cancelled. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ConnectAsync(CancellationToken cancellationToken) => ConnectableSignalAsyncHelper.ConnectAsync(State, cancellationToken); - /// Releases all resources used by the current instance of the class. - /// Call this method when you are finished using the object to release managed resources. + /// Drops any live connection and blocks further ones. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = - "IDisposable.Dispose is intrinsically synchronous; this method must tear down async connection state on the sync dispose path.")] + Justification = "The synchronous dispose contract leaves no way to await teardown of the async connection state.")] public void Dispose() => ConnectableSignalAsyncHelper.Dispose(State); [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs index 5a38e6cf..bf6edcb5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Async; /// Provides connectable-signal operations over flat state records. internal static class ConnectableSignalAsyncHelper { - /// Connects the state source once and returns a handle that can disconnect that connection. + /// Subscribes the state's signal to its source once, returning a handle that disconnects it. /// The type of elements produced by the source sequence. /// The connectable signal state to operate on. /// A token that can cancel connection establishment. @@ -85,8 +85,7 @@ internal static async ValueTask ConnectAsync( [SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = - "IDisposable.Dispose is intrinsically synchronous; this method must tear down async connection state on the sync dispose path.")] + Justification = "The synchronous dispose contract leaves no way to await teardown of the async connection state.")] internal static void Dispose(ConnectableSignalAsyncState state) { if (!state.TryMarkDisposed()) diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs index 28509bf9..7ecc671d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs @@ -17,7 +17,7 @@ internal sealed class ConnectableSignalAsyncState : IDisposable [SuppressMessage( "Style", "SST1401:Field should be private", - Justification = "Gate fields are intentionally direct readonly state for helper access.")] + Justification = "The helper class that drives this state enters the gate directly.")] internal readonly AsyncSerialGate Gate = new(); /// The monitor used to make synchronous disposal idempotent. @@ -45,14 +45,11 @@ public ConnectableSignalAsyncState(IObservableAsync source, ISignalAsync s /// Gets or sets the active source subscription, if connected. internal SingleAssignmentDisposableAsync? Connection { get; set; } - /// Gets or sets a value indicating whether synchronous disposal has run. + /// Gets or sets a value indicating whether disposal has been claimed. internal bool IsDisposed { get; set; } - /// Gets the token canceled when the connectable signal is disposed. Captured while the source is - /// still alive because disposes that source, and reading - /// from a disposed source throws - /// . Disposal always cancels before it disposes, so this token is - /// already cancelled by the time anyone can observe it post-disposal. + /// Gets the token cancelled when the connectable signal is disposed, captured at construction because + /// makes throw. internal CancellationToken DisposedCancellationToken { get; } /// @@ -62,7 +59,7 @@ public void Dispose() DisposedCts.Dispose(); } - /// Marks the state as disposed if disposal has not already started. + /// Claims disposal for the first caller to ask. /// when this call owns disposal; otherwise, . internal bool TryMarkDisposed() { diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs index 23dd9a75..edc1db4e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs @@ -7,14 +7,11 @@ namespace ReactiveUI.Primitives.Async.Disposables; /// Provides factory methods for creating and working with implementations of . -/// This class offers utility members to simplify the creation of asynchronous disposables, such as -/// wrapping a delegate in an implementation or providing a no-op disposable instance. -/// All members are thread-safe and can be used to facilitate resource management in asynchronous scenarios. +/// Every disposable handed out here runs its delegate at most once, however many times — and from however many +/// threads — it is disposed. public static class DisposableAsync { - /// Gets an instance that performs no action when disposed asynchronously. - /// Use this property when an is required but no disposal logic is - /// necessary. This can be useful as a default or placeholder implementation. + /// Gets a shared that does nothing when disposed. public static IAsyncDisposable Empty { get; } = new NoopAsyncDisposable(); /// Creates a new asynchronous disposable object that invokes the specified delegate when disposed asynchronously. @@ -30,9 +27,8 @@ public static IAsyncDisposable Create(Func disposeAsync) /// /// Creates a new asynchronous disposable that invokes the specified delegate, passing the supplied state, when - /// disposed asynchronously. Prefer this overload over at call sites that - /// would otherwise capture locals or this in the lambda — the state-carrying overload removes the - /// closure object and lets the lambda be declared static. + /// disposed asynchronously. Preferred over wherever the lambda would capture + /// locals or this: passing them as state lets the lambda be static and costs no closure. /// /// The type of the state passed to the dispose delegate. /// The state forwarded to at dispose time. @@ -49,7 +45,7 @@ public static IAsyncDisposable Create(TState state, FuncThe delegate invoked to perform asynchronous disposal. internal sealed class DelegateAsyncDisposable(Func disposeAsync) : IAsyncDisposable { - /// A flag indicating whether has already been called (0 = not disposed, 1 = disposed). + /// Guard that keeps the delegate to a single invocation (0 = open, 1 = disposed). private int _disposed; /// @@ -57,16 +53,15 @@ internal sealed class DelegateAsyncDisposable(Func disposeAsync) : IA } /// - /// An asynchronous disposable that invokes a delegate, passing a stored state, when disposed. The state - /// indirection avoids a closure allocation when the caller can supply the captured data as . + /// An asynchronous disposable that invokes a delegate with a stored state when disposed, so the caller's data + /// travels in instead of a closure. /// /// The type of the state passed to the dispose delegate. /// The state forwarded to the dispose delegate at dispose time. /// The delegate invoked to perform asynchronous disposal. internal sealed class DelegateAsyncDisposable(TState state, Func disposeAsync) : IAsyncDisposable { - /// A flag indicating whether has already been called (0 = not disposed, 1 = disposed). + /// Guard that keeps the delegate to a single invocation (0 = open, 1 = disposed). private int _disposed; /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs index c283948a..602cc1fb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs @@ -26,8 +26,6 @@ public static class DisposableAsyncSlot /// Reference to the caller-owned field. /// The new value to store, or to clear the slot. /// A that completes once the previous occupant (if any) has been disposed. - /// The compare-exchange retry (the loop back-edge) is only taken when a concurrent writer - /// wins the race, so it is unreachable by single-threaded tests; excluded from coverage accordingly. [DebuggerStepThrough] [ExcludeFromCodeCoverage] public static ValueTask SwapAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value) @@ -50,14 +48,14 @@ public static ValueTask SwapAsync(ref IAsyncDisposable? slot, IAsyncDisposable? } } - /// Atomically assigns to the slot exactly once. If the slot has - /// already been disposed, is disposed immediately. If the slot already - /// holds a non-null, non-disposed value, throws . - /// Equivalent to . + /// Atomically fills an empty slot with . A closed slot disposes + /// instead. Equivalent to + /// . /// Reference to the caller-owned field. /// The value to assign, or . - /// A that completes once has been disposed - /// (if the slot was already disposed); otherwise a completed task. + /// A that completes once has been disposed when the slot + /// was closed; otherwise a completed task. + /// The slot holds a live occupant. [DebuggerStepThrough] public static ValueTask AssignAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value) { @@ -92,7 +90,7 @@ public static ValueTask DisposeAsync(ref IAsyncDisposable? slot) public static bool IsDisposed(IAsyncDisposable? slot) => ReferenceEquals(slot, DisposedSentinel); - /// Creates an exception indicating that a single-assignment slot already has a value. + /// Creates the exception for a second assignment into a single-assignment slot. /// The invalid-operation exception to throw from the assignment path. internal static InvalidOperationException CreateAlreadyAssignedException() => new("Disposable is already assigned."); diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs index 97445482..dfe94093 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs @@ -10,17 +10,15 @@ namespace ReactiveUI.Primitives.Async.Disposables; /// Represents a thread-safe collection of asynchronous disposable objects that are disposed together as a group. /// Provides methods to add, remove, and asynchronously dispose contained resources as a single operation. /// -/// Use this class to manage the lifetime of multiple resources, ensuring -/// that all are disposed when the collection is disposed. Once disposed, the collection cannot be used to add or remove -/// items. This class is not read-only and is safe for concurrent access from multiple threads. +/// Disposal is one-way: a disposed collection holds nothing, and adding to it disposes the incoming item +/// instead of storing it. Safe for concurrent access from several threads. [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] public sealed class MultipleDisposableAsync : IAsyncDisposable { - /// Capacity allocated on first . Chosen as the typical upper bound - /// of subscriptions a composite holds, so most lifetimes never trigger a resize. + /// Capacity allocated on first , sized so a typical composite never resizes. private const int DefaultCapacity = 8; - /// Length threshold below which Remove no longer compacts the array. + /// Used-slot count at or below which a remove leaves the array uncompacted. private const int ShrinkThreshold = 16; /// Divisor used to decide whether a remove triggers compaction (count * 4 < length). @@ -29,17 +27,16 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable /// Factor the backing array's capacity is multiplied by when it overflows. private const int GrowthFactor = 2; - /// Divisor applied to the backing array's capacity when a sparse collection is compacted. - /// Compaction only runs when fewer than a quarter of the slots are occupied, so halving always leaves room. + /// Divisor applied to the backing array's capacity when a sparse collection is compacted; safe because + /// compaction only runs below quarter occupancy. private const int CompactionShrinkDivisor = 2; /// The synchronization gate protecting all mutable state in this collection. private readonly Lock _gate = new(); /// - /// Backing array of disposables. Slots may be after removal to avoid shifting elements; - /// tracks the high-water mark of used slots and tracks non-null slots. - /// until the first ; the no-arg constructor leaves it unallocated. + /// Backing array, until something is added. A removal zeroes its slot rather than shifting + /// elements, so is the high-water mark and the non-null slots. /// private IAsyncDisposable?[]? _items; @@ -53,8 +50,8 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable private bool _isDisposed; /// - /// Initializes a new instance of the class. The backing array is allocated - /// lazily on the first call; an unused composite costs only its instance header + gate. + /// Initializes a new instance of the class, allocating its backing array on + /// the first call. /// public MultipleDisposableAsync() { @@ -72,7 +69,7 @@ public MultipleDisposableAsync(int capacity) /// /// Initializes a new instance of the class that contains the specified - /// disposables — the backing array is sized exactly so no resize occurs. + /// disposables, sizing the backing array exactly. /// /// An array of objects implementing . public MultipleDisposableAsync(params IAsyncDisposable[] disposables) @@ -149,13 +146,10 @@ public int Count } /// - /// Adds an asynchronous disposable item to the collection, or disposes it immediately if the collection has already - /// been disposed. + /// Takes ownership of a disposable, disposing it on the spot when this collection has been disposed. /// - /// The item to add. The item must implement and will be disposed asynchronously if - /// the collection is disposed. - /// A that represents the asynchronous operation. The returned task is completed if the item - /// was added; otherwise, it represents the asynchronous disposal of the item. + /// The item whose lifetime this collection takes over. Cannot be null. + /// A completed task when the item was stored; otherwise the task disposing it. public ValueTask AddAsync(IAsyncDisposable item) { ArgumentExceptionHelper.ThrowIfNull(item); @@ -179,8 +173,7 @@ public ValueTask AddAsync(IAsyncDisposable item) /// The item to remove and dispose. Cannot be null. /// A task that represents the asynchronous remove operation. The task result is if the item /// was found and removed; otherwise, . - /// If the item is not found in the collection, it is not disposed. This method is - /// thread-safe. + /// An item this collection does not hold is left alone, not disposed. public async ValueTask Remove(IAsyncDisposable item) { ArgumentExceptionHelper.ThrowIfNull(item); @@ -216,10 +209,10 @@ public async ValueTask Remove(IAsyncDisposable item) return true; } - /// Asynchronously disposes all items in the collection and removes them. - /// A task that represents the asynchronous clear operation. - /// If the collection is already empty or has been disposed, this method performs no action. Each - /// item is disposed asynchronously before being removed from the collection. This method is thread-safe. + /// Empties the collection and disposes everything it held, leaving it reusable. + /// A task that completes once every item has been disposed. + /// Items are disposed one after another in insertion order, outside the lock, so the collection accepts + /// additions while the disposals are in flight. public async ValueTask Clear() { IAsyncDisposable?[] rented; @@ -257,9 +250,8 @@ public async ValueTask Clear() /// Determines whether the collection contains the specified asynchronous disposable item. /// The asynchronous disposable item to locate in the collection. Can be null. - /// true if the specified item is found in the collection and the collection has not been disposed; otherwise, - /// false. - /// If the collection has been disposed, this method always returns false. + /// when the collection is live and holds the item; otherwise + /// . public bool Contains(IAsyncDisposable item) { lock (_gate) @@ -275,6 +267,7 @@ public bool Contains(IAsyncDisposable item) /// length of the array. /// Thrown when arrayIndex is less than zero, greater than or equal to the length of array, or when there is not /// enough space from arrayIndex to the end of array to accommodate all elements in the collection. + /// A disposed collection copies nothing and raises nothing. public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) { if (arrayIndex < 0 || arrayIndex >= array?.Length) @@ -308,9 +301,7 @@ public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) /// disposable object. /// /// A task that represents the asynchronous dispose operation. - /// After calling this method, the collection is considered disposed and cannot be used. This - /// method is thread-safe and can be called multiple times; subsequent calls after the first have no - /// effect. + /// Idempotent. Items are disposed one after another in insertion order. public async ValueTask DisposeAsync() { IAsyncDisposable?[]? snapshot; diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs index 859f45e4..4297e435 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs @@ -11,68 +11,57 @@ namespace ReactiveUI.Primitives.Async.Disposables; /// Represents an asynchronously disposable resource that allows a single assignment of its underlying disposable. Once /// disposed, further assignments will dispose the assigned resource immediately. /// -/// This type is useful for scenarios where an asynchronous disposable resource must be assigned exactly -/// once, and where disposal may occur before or after the assignment. If disposed before assignment, any subsequently -/// assigned resource will be disposed immediately. This class is not thread-safe for concurrent assignment and -/// disposal; external synchronization is required if used from multiple threads. +/// A second assignment throws , so this type suits the common shape +/// where a subscription handle has to be stored before the work it cancels can produce it. [System.Diagnostics.DebuggerDisplay("SingleAssignmentDisposableAsync: IsDisposed = {IsDisposed}, Current = {_current}")] public sealed class SingleAssignmentDisposableAsync : IAsyncDisposable { - /// The currently assigned disposable resource, or the disposed sentinel if already disposed. + /// The assigned disposable, or the sentinel that marks the slot closed. private IAsyncDisposable? _current; /// Gets a value indicating whether the object has been disposed. public bool IsDisposed => DisposableAsyncSlot.IsDisposed(Volatile.Read(ref _current)); - /// Gets the current asynchronous disposable resource, or an empty disposable if the resource has already been disposed. - /// An representing the current resource, or if - /// the resource has been disposed. Returns if no resource is set. + /// Gets the assigned disposable, hiding the internal sentinel behind . + /// The assigned resource, after disposal, or + /// when nothing has been assigned. public IAsyncDisposable? GetDisposable() { var field = Volatile.Read(ref _current); return DisposableAsyncSlot.IsDisposed(field) ? DisposableAsync.Empty : field; } - /// Asynchronously sets the current disposable resource to the specified value, replacing any previously set resource. - /// The new instance to set as the current resource, or to - /// clear the current resource. - /// A that represents the asynchronous operation. + /// Assigns the resource this instance owns, disposing on the spot when this + /// instance has been disposed. + /// The to take ownership of, or . + /// A that completes once any disposal this call triggered has finished. + /// A resource has been assigned by an earlier call. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SetDisposableAsync(IAsyncDisposable? value) => AssignDisposableAsync(ref _current, value); - /// Asynchronously releases the unmanaged resources used by the object. - /// A ValueTask that represents the asynchronous dispose operation. + /// Disposes the assigned resource and closes the slot, so a later assignment disposes its argument. + /// A that completes once the assigned resource has been disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask DisposeAsync() => DisposeAsync(ref _current); - /// Atomically assigns an asynchronous disposable object to the specified field if it has not already been set. - /// A reference to the field that will hold the assigned instance. The field must - /// initially be null. - /// The instance to assign to the field, or null to leave the field unset. - /// A that represents the asynchronous dispose operation if the field was already disposed; - /// otherwise, a default . - /// If the field has already been assigned or disposed, the method either throws an exception or - /// disposes the provided value, as appropriate. This method is intended for use in thread-safe scenarios where a - /// disposable resource should only be set once. + /// Assigns into an empty caller-owned field, with no wrapper instance. + /// A reference to the field that takes ownership of . + /// The to assign, or . + /// A that completes once any disposal this call triggered has finished. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static ValueTask AssignDisposableAsync(ref IAsyncDisposable? field, IAsyncDisposable? value) => DisposableAsyncSlot.AssignAsync(ref field, value); - /// Asynchronously disposes the object referenced by the specified field, if it has not already been disposed. - /// A reference to an field to be disposed. The field will be set to a sentinel value - /// to prevent multiple disposals. - /// A that represents the asynchronous dispose operation. The returned task is completed if - /// the field was already disposed or null. - /// This method is intended for use in thread-safe disposal patterns to ensure that the - /// referenced object is disposed only once. After calling this method, the field will reference a sentinel value - /// indicating it has been disposed. + /// Disposes a caller-owned field's occupant once and leaves the field holding the closed sentinel. + /// A reference to the field to close. + /// A that completes once the occupant has been disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] [DebuggerStepThrough] internal static ValueTask DisposeAsync(ref IAsyncDisposable? field) => DisposableAsyncSlot.DisposeAsync(ref field); - /// Creates an exception indicating that the disposable has already been assigned. - /// An with the already-assigned message. + /// Creates the exception for a second assignment. + /// The to throw from the assignment path. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static InvalidOperationException CreateAlreadyAssignedException() => DisposableAsyncSlot.CreateAlreadyAssignedException(); diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs index 9e305c6c..b23dcbb7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs @@ -10,38 +10,29 @@ namespace ReactiveUI.Primitives.Async.Disposables; /// Provides a thread-safe mechanism for managing a single asynchronously disposable resource that can be replaced or /// disposed of serially. /// -/// When a new disposable is set using SetDisposableAsync, the previously held disposable (if any) is -/// asynchronously disposed. Disposing the SerialDisposableAsync instance disposes the current disposable and prevents -/// further disposables from being set. This class is useful for scenarios where a resource needs to be replaced or -/// updated over time, ensuring that only one resource is active and properly disposed of at any given moment. All -/// operations are safe to use concurrently from multiple threads. +/// Exactly one resource is live at a time: each assignment disposes the one it displaces, and disposing this +/// instance closes the slot so a further assignment disposes its argument. Safe to use from several threads. [System.Diagnostics.DebuggerDisplay("SingleReplaceableDisposableAsync: Current = {_current}")] public class SingleReplaceableDisposableAsync : IAsyncDisposable { - /// The currently tracked disposable resource, or the disposed sentinel if already disposed. + /// The tracked disposable, or the sentinel that marks the slot closed. private IAsyncDisposable? _current; /// - /// Replaces the currently tracked asynchronous disposable resource with a new one, disposing the previous resource - /// if present. + /// Takes over from the tracked resource, disposing the one it displaces — or disposing + /// itself when this instance has been disposed. /// /// The new instance to track. Can be to clear the current /// resource. - /// A that represents the asynchronous dispose operation of the previously tracked resource, - /// or of if the object has already been disposed. If there is no resource to dispose, the - /// returned task is already completed. - /// If the object has already been disposed, is disposed immediately. - /// Otherwise, the previously tracked resource, if any, is disposed asynchronously. This method is - /// thread-safe. + /// A that completes once whichever resource this call gave up has been + /// disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SetDisposableAsync(IAsyncDisposable? value) => DisposableAsyncSlot.SwapAsync(ref _current, value); - /// Asynchronously releases the resources used by the object. - /// A ValueTask that represents the asynchronous dispose operation. The task will be completed when all resources - /// have been released. - /// Subsequent calls to this method after disposal will have no effect. This method is safe to - /// call multiple times. + /// Disposes the tracked resource and closes the slot. + /// A that completes once the tracked resource has been disposed. + /// Idempotent. public ValueTask DisposeAsync() { GC.SuppressFinalize(this); diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs index 0bfa6f5d..5e3c1a65 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs @@ -17,9 +17,9 @@ public static class DisposalHelper [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static bool HasDisposed(int disposed) => disposed == 1; - /// Atomically sets the disposed flag and returns whether it was already set. + /// Atomically raises the disposed flag, reporting whether an earlier call got there first. /// A reference to the disposed flag. - /// if already disposed; otherwise . + /// when the flag was set before this call; otherwise . [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static bool TrySetDisposed(ref int disposed) => Interlocked.Exchange(ref disposed, 1) == 1; } diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs index 72bde3ce..01fab590 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs @@ -7,9 +7,8 @@ namespace ReactiveUI.Primitives.Async.Helpers; /// -/// Provides a helper for executing async actions as fire-and-forget with exception swallowing. -/// Used for async void callbacks (e.g. cancellation token registrations, signal handlers) -/// where exceptions cannot propagate to a caller. +/// Runs asynchronous work from a synchronous callback — a cancellation-token registration, a signal handler — where +/// there is no caller to observe a task, reporting failures to . /// [ExcludeFromCodeCoverage] public static class FireAndForgetHelper @@ -19,9 +18,7 @@ public static class FireAndForgetHelper [SuppressMessage( "Concurrency", "SST1905:Do not use async void", - Justification = - "Intentional fire-and-forget helper. There is no caller to hand a Task back to; failures are routed to the " - + "global handler instead. Returning Task would defeat the fire-and-forget contract this helper exists to provide.")] + Justification = "There is no caller to hand a Task back to, so failures go to the global handler instead.")] [SuppressMessage( "ReSharper", "AsyncVoidMethod", diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs index 7f451a6b..e8f50932 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs @@ -10,10 +10,11 @@ namespace ReactiveUI.Primitives.Async.Helpers; /// public static class SubscriptionHelper { - /// Executes and returns . + /// Runs , disposing and rethrowing if it + /// fails, so a half-built subscription is never handed back. /// The subscription to manage. /// The async action that wires up the subscription. - /// The subscription if successful. + /// The subscription, once wiring succeeded. internal static async ValueTask SubscribeAndDisposeOnFailureAsync( IAsyncDisposable subscription, Func subscribeAsync) diff --git a/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs index bed67c88..4d507c84 100644 --- a/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs @@ -6,19 +6,15 @@ namespace ReactiveUI.Primitives.Async; /// Represents a provider for asynchronous push-based notifications that supports asynchronous subscription and disposal. /// The type of elements produced by the observable sequence. -/// Use this interface to implement observable sequences that allow observers to subscribe asynchronously -/// and receive notifications in an asynchronous manner. This is useful for scenarios where subscription or -/// unsubscription may involve asynchronous operations, such as network or I/O-bound tasks. Implementations should -/// ensure that notifications are delivered according to the observer's contract and that resources are released when -/// the subscription is disposed. +/// Subscription and unsubscription are themselves awaitable, so a sequence backed by network or I/O work can +/// complete its setup and teardown before the caller proceeds. public interface IObservableAsync { /// Subscribes the specified asynchronous observer to receive notifications from the observable sequence. /// The observer that will receive asynchronous notifications. Cannot be null. /// A cancellation token that can be used to cancel the subscription operation. - /// A task that represents the asynchronous subscription operation. The result contains an that can be disposed to unsubscribe the observer. - /// The returned should be disposed when the observer no longer - /// wishes to receive notifications. Multiple observers may be subscribed concurrently. + /// A handle whose disposal unsubscribes the observer. + /// Implementations must tolerate several observers subscribed at once, and must release the + /// subscription's resources when the returned handle is disposed. ValueTask SubscribeAsync(IObserverAsync observer, CancellationToken cancellationToken); } diff --git a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs index 688defaf..1bef1a1c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs @@ -9,27 +9,26 @@ namespace ReactiveUI.Primitives.Async; /// supports asynchronous resource cleanup. /// /// The type of the elements received by the observer. -/// Implementations of this interface allow for non-blocking, asynchronous handling of data streams, -/// including support for cancellation and proper disposal of resources. This is useful in scenarios where observers -/// need to process events or data asynchronously, such as in reactive or event-driven programming models. +/// Every notification is awaitable and cancellable, so a producer that awaits them gets backpressure for +/// free: the observer's handler has to finish before the next value is pushed. public interface IObserverAsync : IAsyncDisposable { - /// Performs asynchronous completion logic in response to the specified result. - /// The result object that provides information about the completed operation. Cannot be null. - /// A ValueTask that represents the asynchronous completion operation. + /// Signals that the sequence has terminated, successfully or with a failure. + /// The terminal outcome, carrying the failure when the sequence faulted. + /// A task that completes when the observer has handled the termination. ValueTask OnCompletedAsync(Result result); - /// Handles the specified error and resumes asynchronous processing, if possible. - /// The exception that caused the error. Cannot be null. + /// Reports a non-terminal error, leaving the sequence free to carry on. + /// The exception to report. /// A cancellation token that can be used to cancel the error handling operation. - /// A ValueTask that represents the asynchronous error handling operation. - /// Implementations may choose to suppress the error and continue processing, or perform cleanup - /// and terminate gracefully. The behavior depends on the specific implementation. + /// A task that completes when the observer has handled the error. + /// Unlike a faulted , this does not end the sequence; an implementation + /// chooses whether to swallow the error or tear itself down. ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken); - /// Asynchronously processes the next value in the sequence. + /// Delivers the next value in the sequence. /// The value to be processed. /// A cancellation token that can be used to cancel the asynchronous operation. - /// A task that represents the asynchronous operation. + /// A task that completes when the observer has consumed the value. ValueTask OnNextAsync(T value, CancellationToken cancellationToken); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs b/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs index d89f7b5b..7dc5b4b0 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for converting synchronous disposable objects to asynchronous disposables. -/// These extension methods enable the use of existing IDisposable implementations in asynchronous -/// disposal scenarios by wrapping them as IAsyncDisposable. This is useful when working with APIs that require -/// asynchronous disposal, but only a synchronous Dispose method is available. public static class DisposableAsyncExtensions { /// Asynchronous-disposal wrapping operators for an instance. @@ -19,13 +16,11 @@ public static class DisposableAsyncExtensions /// Converts an instance to an wrapper. /// An that disposes the underlying when disposed /// asynchronously. - /// The returned invokes the synchronous method when is called. This is useful - /// for integrating synchronous disposables into asynchronous disposal patterns. + /// Disposal runs synchronously on the caller's thread; the returned handle only adapts the shape. [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The suffix names the IAsyncDisposable the method returns, not asynchronous work.")] public IAsyncDisposable ToDisposableAsync() { ArgumentExceptionHelper.ThrowIfNull(disposable); @@ -35,14 +30,10 @@ public IAsyncDisposable ToDisposableAsync() } /// - /// Provides an implementation of that wraps a synchronous - /// instance, enabling it to be used in asynchronous disposal scenarios. + /// Presents a synchronous as an , calling + /// inline and completing synchronously. /// /// The instance to be wrapped for asynchronous disposal. Cannot be null. - /// This class allows objects that implement but not to be used in contexts that require asynchronous disposal. The asynchronous dispose - /// operation is performed by invoking the synchronous method; no actual - /// asynchronous work is performed. internal sealed class DisposableToDisposableAsync(IDisposable disposable) : IAsyncDisposable { /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs b/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs index 2eb613a0..6979bd74 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs @@ -9,9 +9,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with signals in a reactive programming context. -/// The methods in this class enable interoperability between signals and asynchronous observer -/// patterns. These extensions are intended to simplify the integration of signals with APIs that expect asynchronous -/// observers. public static class SignalExtensions { /// Observer-wrapping and value-mapping operators for a signal source. @@ -24,7 +21,7 @@ public static class SignalExtensions [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The suffix names the IObserverAsync the method returns, not asynchronous work.")] public IObserverAsync AsObserverAsync() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -38,10 +35,10 @@ public IObserverAsync AsObserverAsync() /// /// A function that takes an asynchronous observable of type T and returns a transformed asynchronous observable of /// type T. This function defines how the values are mapped. - /// A signal that emits values transformed by the specified mapping function. - /// The returned signal reflects the mapped values of the original signal. Subscribers to the - /// returned signal will observe the transformed sequence as defined by the mapper function. The mapping is applied - /// to all values published by the source signal. + /// A signal that publishes into but exposes the mapped sequence to its own + /// subscribers. + /// runs once, against the source's value sequence, rather than per + /// subscriber. public ISignalAsync MapValues(Func, IObservableAsync> mapper) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -95,13 +92,9 @@ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellat internal sealed class SignalAsyncWitness(ISignalAsync signal) : WitnessAsync { /// - /// Forwards the value to the wrapped signal. The cancellation token is intentionally - /// replaced with rather than passing our own dispose - /// token through: subscribers downstream of the signal are - /// wraps whose TryEnter short-circuits on via - /// its fast path, avoiding a per-emission linked-CTS allocation on every observer. The - /// upstream-disposal cascade is unaffected — by the time this observer is disposed (on - /// source completion / error) no further OnNext calls reach this method. + /// Forwards the value to the wrapped signal under , which every downstream + /// takes its no-link fast path on. Passing this observer's own dispose token + /// instead would buy nothing: disposal stops values from reaching this method at all. /// /// The value to be processed by the observer. /// The token captured by the base observer's TryEnter scope. Ignored on the forward. @@ -122,9 +115,9 @@ protected override ValueTask OnErrorResumeAsyncCore(Exception error, Cancellatio return signal.OnErrorResumeAsync(error, CancellationToken.None); } - /// Performs asynchronous completion logic when the operation has finished, using the specified result. + /// Forwards the terminal result to the wrapped signal. /// The result of the completed operation, containing any relevant outcome information. - /// A ValueTask that represents the asynchronous completion operation. + /// A task that completes when the signal has handled the result. protected override ValueTask OnCompletedAsyncCore(Result result) => signal.OnCompletedAsync(result); } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs index 05a43a83..fe75b5d9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs @@ -10,10 +10,8 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating asynchronous observables and background jobs that emit values to observers /// asynchronously. /// -/// The methods in this class allow developers to construct asynchronous observables by supplying custom -/// subscription logic or background jobs. Observables created with these methods support asynchronous notification and -/// cancellation, enabling integration with modern async workflows. Use these methods to bridge asynchronous producers -/// with consumers following the observer pattern. +/// These are the entry points for turning an arbitrary asynchronous producer into a sequence: supply the +/// subscribe logic yourself, or hand over a job to run per subscriber. public static partial class SignalAsync { /// Creates a new asynchronous observable sequence using the specified subscription function. @@ -21,11 +19,10 @@ public static partial class SignalAsync /// A function that is invoked when an observer subscribes to the sequence. The function receives an asynchronous /// observer and a cancellation token, and returns a task that yields a disposable resource representing the /// subscription. - /// An SignalAsync{T} that invokes the specified subscription function for each observer. + /// An observable sequence that runs for each observer. /// Thrown if is . - /// The subscription function is responsible for handling observer notifications and managing the - /// lifetime of the subscription. The returned disposable should release any resources or cancel ongoing operations - /// when disposed. + /// The disposable returns owns the subscription's resources and must + /// stop the producer when disposed. public static IObservableAsync Create( Func, CancellationToken, ValueTask> subscribeAsync) => subscribeAsync is null @@ -36,7 +33,7 @@ subscribeAsync is null /// The type of elements produced by the observable sequence. /// A delegate that defines the asynchronous job to execute. The delegate receives an observer to report results and /// a cancellation token to observe cancellation requests. - /// An SignalAsync{T} that represents the observable sequence produced by the background job. + /// An observable sequence that runs on a scheduled task per subscriber. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync CreateAsBackgroundJob( Func, CancellationToken, ValueTask> job) => @@ -48,7 +45,7 @@ public static IObservableAsync CreateAsBackgroundJob( /// a cancellation token to observe cancellation requests. /// true to start the job synchronously on the calling thread; otherwise, false to schedule it to run /// asynchronously. - /// An SignalAsync{T} that represents the observable sequence produced by the background job. + /// An observable sequence that runs per subscriber. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync CreateAsBackgroundJob( Func, CancellationToken, ValueTask> job, @@ -63,7 +60,8 @@ public static IObservableAsync CreateAsBackgroundJob( /// A delegate that defines the asynchronous job to execute. The delegate receives an observer to report results and /// a cancellation token to observe cancellation requests. /// The task scheduler that is used to schedule the background job. - /// An SignalAsync{T} that represents the asynchronous background job and emits the results produced by the job. + /// An observable sequence that runs per subscriber on + /// . [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync CreateAsBackgroundJob( Func, CancellationToken, ValueTask> job, diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs index ef84854f..15672725 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs @@ -10,21 +10,16 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating observable sequences that are generated by invoking a specified function each /// time a new observer subscribes. /// -/// The methods in this class enable deferred creation of asynchronous observable sequences. Each -/// subscription triggers a new invocation of the provided factory function, ensuring that each observer receives a -/// fresh sequence. This is useful for scenarios where the observable's behavior or state should be determined at the -/// time of subscription rather than at the time of declaration. +/// Nothing runs at declaration: the factory is called once per subscriber, so each one gets a sequence built +/// from the state that holds at its own subscribe time. public static partial class SignalAsync { /// Creates a new observable sequence for each subscription by invoking the specified asynchronous factory function. /// The type of the elements produced by the observable sequence. /// A function that receives a cancellation token and returns a task that produces an observable sequence to /// subscribe to. - /// An observable sequence that, upon each subscription, invokes the factory function to obtain the actual - /// observable sequence to subscribe to. - /// Use this method to defer the creation of the observable sequence until an observer - /// subscribes. This is useful when the observable sequence depends on per-subscription state or resources, or when - /// you want to ensure a fresh sequence for each subscriber. + /// An observable sequence that awaits on each subscribe and subscribes to + /// whatever it yields. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Defer(Func>> factory) => new DeferAsyncSignalAsync(factory); @@ -35,19 +30,14 @@ public static IObservableAsync Defer(Func /// The type of the elements produced by the observable sequence. /// A function that returns a new instance of an observable sequence to be subscribed to for each observer. - /// An observable sequence whose observers trigger the invocation of the factory function upon subscription. - /// Use this method to defer the creation of the observable sequence until an observer - /// subscribes, ensuring that each subscription receives a fresh instance. This is useful when the observable - /// sequence has side effects or depends on external state at the time of subscription. + /// An observable sequence that calls on each subscribe and subscribes to + /// whatever it returns. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Defer(Func> factory) => new DeferSyncSignalAsync(factory); - /// Dedicated observable for . - /// Holds the factory delegate directly — no closure-capturing lambda, no - /// -wrapper indirection. Per-subscribe path invokes the factory, - /// wraps the downstream observer once for contract compliance, and subscribes to the - /// freshly-produced inner observable. + /// Backs : each subscribe calls the factory and hands the + /// wrapped observer to the sequence it returns. /// The element type. /// The deferred factory invoked once per subscribe. internal sealed class DeferSyncSignalAsync(Func> factory) : IObservableAsync @@ -60,10 +50,9 @@ ValueTask IObservableAsync.SubscribeAsync( factory().SubscribeAsync(observer.Wrap(), cancellationToken); } - /// Dedicated observable for the -returning - /// . - /// Same allocation profile as with one extra - /// state-machine box per call to host the factory's await. + /// Backs the -returning + /// , awaiting the factory before it + /// can subscribe. /// The element type. /// The deferred factory invoked once per subscribe. internal sealed class DeferAsyncSignalAsync(Func>> factory) : IObservableAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs index f437ef53..7b475e0f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs @@ -9,23 +9,17 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating asynchronous observable sequences. -/// The SignalAsync class contains static methods for constructing instances of asynchronous -/// observables. Use these methods to create observable sequences that support asynchronous notification -/// patterns. public static partial class SignalAsync { /// Creates an observable sequence that completes immediately without emitting any items. /// The type of elements in the observable sequence. /// An observable sequence of type that completes immediately without producing any values. - /// This method is useful for representing an empty sequence in asynchronous or reactive - /// scenarios. The returned sequence signals completion to observers as soon as it is subscribed to. - /// The returned instance is a process-wide singleton per element type — no allocation occurs after the - /// first call for a given . + /// The returned instance is a singleton per element type. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: SignalAsync.Empty().")] + Justification = "There are no parameters to infer from; the caller states the element type: SignalAsync.None().")] public static IObservableAsync None() => EmptySignalAsync.Instance; /// Creates an observable sequence that completes immediately without emitting any items. @@ -35,13 +29,12 @@ public static partial class SignalAsync [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: SignalAsync.Empty().")] + Justification = "There are no parameters to infer from; the caller states the element type: SignalAsync.Empty().")] public static IObservableAsync Empty() => EmptySignalAsync.Instance; /// - /// Dedicated singleton observable that signals immediate successful completion on subscribe. Replaces the - /// previous Create<T>((observer, _) => ...) + shape with a - /// per-T cached instance — no anonymous observable wrapper, no closure, no per-subscribe allocation. + /// Signals successful completion on subscribe and hands back , from a cached + /// instance per element type. /// /// The element type of the empty sequence. internal sealed class EmptySignalAsync : IObservableAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/FromAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/FromAsync.cs index 65139b4e..9ce1a00c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/FromAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/FromAsync.cs @@ -7,10 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating asynchronous observables from asynchronous operations. -/// The methods in this class allow integration of asynchronous tasks or functions into the observable -/// pattern, enabling consumers to subscribe to results produced by asynchronous operations. All created observables -/// execute the provided asynchronous factory as a background job and emit the result to subscribers upon -/// completion. public static partial class SignalAsync { /// Creates an asynchronous observable sequence that emits a single value produced by the specified factory function. @@ -18,14 +14,13 @@ public static partial class SignalAsync /// A function that asynchronously produces a value of type when invoked with a . Cannot be null. /// An observable that emits the value returned by the factory function and then completes. - /// Thrown if is null. - /// The observable sequence will emit the value produced by the factory and then signal - /// completion. The factory function is invoked when the sequence is subscribed to, and supports cancellation via - /// the provided . + /// Thrown if is null. + /// runs once per subscriber, started by the subscribe call rather than awaited + /// by it. [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The name states where the values come from; the method itself returns a sequence synchronously.")] public static IObservableAsync FromAsync(Func> factory) { ArgumentExceptionHelper.ThrowIfNull(factory); diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs index 52c67094..92afac0e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs @@ -7,19 +7,12 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating asynchronous observable sequences. -/// The SignalAsync class offers static methods to construct observables that emit values -/// asynchronously. These methods are useful for scenarios where data needs to be produced or streamed over time, such -/// as timers or event-driven sequences. All members of this class are thread-safe and can be used from multiple threads -/// concurrently. public static partial class SignalAsync { /// Creates an asynchronous observable sequence that emits a long integer value at each specified time interval. /// The time interval between emissions of values. Must be a positive duration. - /// An SignalAsync{long} that emits an increasing long value at each interval, starting from 1, until the - /// sequence is cancelled. - /// The sequence continues emitting values until the observer unsubscribes or the cancellation - /// token is triggered. This method is useful for generating periodic events or timers in asynchronous - /// workflows. + /// An observable sequence that emits an increasing count, starting at 1, on every tick until the + /// subscription is disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Interval(TimeSpan period) => new IntervalSignal(period, null); @@ -28,11 +21,8 @@ public static IObservableAsync Interval(TimeSpan period) => /// The time interval between emissions of values. Must be a positive duration. /// An optional time provider used to control the timing of emissions. If null or set to TimeProvider.System, the /// system clock is used. - /// An SignalAsync{long} that emits an increasing long value at each interval, starting from 1, until the - /// sequence is cancelled. - /// The sequence continues emitting values until the observer unsubscribes or the cancellation - /// token is triggered. This method is useful for generating periodic events or timers in asynchronous - /// workflows. + /// An observable sequence that emits an increasing count, starting at 1, on every tick until the + /// subscription is disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Interval(TimeSpan period, TimeProvider? timeProvider) => new IntervalSignal(period, timeProvider); diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs index de82c6a9..24376c6f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs @@ -9,22 +9,17 @@ namespace ReactiveUI.Primitives.Async; /// Provides static methods for creating and composing asynchronous observable sequences. -/// This class contains factory and utility methods for working with asynchronous observables. Use these -/// methods to construct, transform, or combine observable sequences in asynchronous scenarios. All members are -/// thread-safe and can be used in concurrent environments. public static partial class SignalAsync { /// Creates an observable sequence that never produces any values and never completes. /// The type of elements in the observable sequence. /// An observable sequence of type that never emits any items and never terminates. - /// This method is useful for testing or composing observables where a sequence that remains idle - /// is required. The returned observable will not invoke any callbacks and will not signal completion or - /// error. + /// The returned instance is a singleton per element type. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: SignalAsync.Never().")] + Justification = "There are no parameters to infer from; the caller states the element type: SignalAsync.Never().")] public static IObservableAsync Never() => NeverSignalAsync.Instance; /// An observable sequence that never produces any values and never completes. diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Range.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Range.cs index 927128c2..6818f405 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Range.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Range.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides static methods for creating and manipulating asynchronous observable sequences. -/// The SignalAsync class offers factory methods and utilities for working with asynchronous -/// observables, enabling reactive programming patterns with support for asynchronous event streams. Members of this -/// class are thread-safe and designed for use in concurrent environments. public static partial class SignalAsync { /// Creates an observable sequence that emits a range of consecutive integer values, starting from the specified value. @@ -17,9 +14,7 @@ public static partial class SignalAsync /// The number of sequential integers to emit. Must be non-negative. /// An observable sequence that emits integers from to + - 1, in order. - /// The sequence completes after emitting all values. If is zero, the - /// sequence completes immediately without emitting any values. The operation supports cancellation via the - /// observer's cancellation token. + /// A of zero completes the sequence without emitting anything. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Sequence(int start, int count) => new SequenceSignal(start, count); @@ -32,8 +27,6 @@ public static partial class SignalAsync [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "Range is the System.Reactive name for Sequence. Both operators intentionally build the same signal " - + "directly rather than one forwarding to the other, so the Rx-named alias costs nothing at the call site.")] + Justification = "Range is the Rx-compatible alias for Sequence and builds the same signal with no forwarding hop.")] public static IObservableAsync Range(int start, int count) => new SequenceSignal(start, count); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs index 442306b6..448eda43 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs @@ -7,18 +7,14 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating asynchronous observable sequences. -/// The SignalAsync class contains static methods for constructing and manipulating asynchronous -/// observables. Use these methods to create observables that emit values asynchronously, supporting scenarios such as -/// background processing or integration with asynchronous workflows. public static partial class SignalAsync { /// Creates an observable sequence that emits a single value and then completes. /// The type of the value to be emitted by the observable sequence. /// The value to be emitted by the observable sequence. /// An observable sequence that emits the specified value and then signals completion. - /// The returned observable sequence emits the value asynchronously and completes immediately - /// after. Subscribe returns before the value is observed — emit + complete are scheduled onto the - /// threadpool, preserving the deferred semantic the surrounding pipeline expects. + /// Subscribing returns before the value is observed: the emit and the completion are scheduled onto the + /// thread pool, so an observer must not assume it has seen the value by the time subscribe finishes. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Emit(T value) => new ReturnSignalAsync(value); @@ -30,15 +26,12 @@ public static partial class SignalAsync [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "Return is the System.Reactive name for Emit. Both operators intentionally build the same signal directly " - + "rather than one forwarding to the other, so the Rx-named alias costs nothing at the call site.")] + Justification = "Return is the Rx-compatible alias for Emit and builds the same signal with no forwarding hop.")] public static IObservableAsync Return(T value) => new ReturnSignalAsync(value); /// - /// Single-value observable that captures the emitted value as a field and routes through a typed - /// . Same deferred-emit semantic as the previous - /// CreateAsBackgroundJob path, but without the per-call Func closure allocation. + /// Holds the single value in a field and emits it from a started per + /// subscriber, so the emit is deferred without a delegate to close over. /// /// The element type emitted. /// The captured value emitted on each subscribe. diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Throw.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Throw.cs index c55125b6..fbc2ffb6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Throw.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Throw.cs @@ -9,22 +9,18 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating asynchronous observable sequences. -/// The SignalAsync class offers static methods to construct and manipulate asynchronous observables. -/// Use these methods to create sequences that emit values, errors, or completion notifications in an asynchronous -/// manner. public static partial class SignalAsync { /// Creates an observable sequence that terminates immediately with the specified exception. /// The type of the elements in the observable sequence. /// The exception to be propagated to observers as an error notification. Cannot be null. /// An observable sequence of type that signals the specified exception upon subscription. - /// Thrown if is null. - /// Use this method to create an observable sequence that fails immediately, which can be useful - /// for testing error handling or representing error conditions in reactive workflows. + /// is . + /// The exception arrives as a terminal completion, not thrown from the subscribe call. [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: SignalAsync.Throw(ex).")] + Justification = "The element type cannot be inferred from an exception; the caller states it: SignalAsync.Fail(ex).")] public static IObservableAsync Fail(Exception error) { ArgumentExceptionHelper.ThrowIfNull(error); @@ -41,16 +37,13 @@ public static IObservableAsync Fail(Exception error) [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: SignalAsync.Throw(ex).")] + Justification = "The element type cannot be inferred from an exception; the caller states it: SignalAsync.Throw(ex).")] public static IObservableAsync Throw(Exception error) => new ThrowSignalAsync(error ?? throw new ArgumentNullException(nameof(error))); /// Represents an asynchronous observable sequence that immediately terminates with the specified exception. /// The type of elements in the observable sequence. /// The exception that will be signaled to observers as the terminal error. - /// Use this type to create an observable sequence that fails immediately upon subscription, - /// propagating the provided exception to subscribers. This can be useful for representing error conditions in - /// asynchronous observable scenarios. internal sealed class ThrowSignalAsync(Exception error) : IObservableAsync { /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs index d9025f8a..ec2324dc 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs @@ -7,10 +7,8 @@ namespace ReactiveUI.Primitives.Async; /// -/// Provides the Timer factory method for creating asynchronous observable sequences that produce -/// a single value after a specified delay. +/// Provides the clock-driven factory methods: a one-shot value after a delay, and periodic ticks. /// -/// Timer is useful for triggering one-shot deferred actions in observable pipelines. public static partial class SignalAsync { /// Creates an observable sequence that produces a single value (0) after the specified delay, then completes. diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs index 4e841b0b..3017f5da 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs @@ -11,10 +11,8 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for converting tasks, asynchronous enumerables, and enumerable sequences into /// asynchronous observable sequences. /// -/// The methods in this class enable integration of task-based and enumerable workflows with asynchronous -/// observables. Each method returns an observable sequence that emits values or completion notifications based on the -/// source sequence or task. Cancellation and error propagation are supported according to the source's behavior. These -/// extensions are useful for bridging between different asynchronous programming models. +/// Every overload is spelled ToAsyncSignal, so the conversion reads the same whatever the source, and a +/// sequence that is an passes straight through. public static partial class SignalAsyncExtensions { /// Observable-conversion operators for an asynchronous enumerable source. @@ -24,14 +22,12 @@ public static partial class SignalAsyncExtensions { /// Converts an asynchronous enumerable sequence to an asynchronous observable sequence. /// An asynchronous observable sequence that emits the elements of the source sequence. - /// The returned observable emits each element from the source sequence as it is produced and - /// signals completion when the source sequence ends. Cancellation is supported via the observer's cancellation - /// token. + /// The source is enumerated once per subscriber. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The suffix names the asynchronous signal the method returns, not asynchronous work.")] public IObservableAsync ToAsyncSignal() => new AsyncEnumerableSignal(source); } @@ -46,13 +42,13 @@ public static partial class SignalAsyncExtensions /// /// An asynchronous observable sequence that emits each element from the source enumerable and completes when all /// elements have been emitted. - /// The returned observable emits items on a background thread. Cancellation is supported via the - /// observer's cancellation token. If the source sequence is empty, the observable completes immediately. + /// Enumeration runs on a background thread, once per subscriber, so a blocking or side-effecting + /// sequence neither stalls the subscribe call nor is shared between observers. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The suffix names the asynchronous signal the method returns, not asynchronous work.")] public IObservableAsync ToAsyncSignal() => new EnumerableSignal(source); } @@ -61,13 +57,14 @@ public static partial class SignalAsyncExtensions /// The source sequence. extension(IObservableAsync source) { - /// Returns an async observable as an async signal. - /// An observable sequence validated. + /// Null-checks and returns the source unchanged, so generic code can convert without knowing whether + /// it has a signal already. + /// The same sequence. /// is . [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The suffix names the asynchronous signal the method returns, not asynchronous work.")] public IObservableAsync ToAsyncSignal() => source ?? throw new ArgumentNullException(nameof(source)); } @@ -83,14 +80,13 @@ public IObservableAsync ToAsyncSignal() => /// /// An asynchronous observable sequence that emits the result of the task when it completes, followed by a /// completion notification. - /// The returned observable will emit the task's result and then complete. If the task is - /// canceled or fails, the observable will propagate the corresponding error. The task is awaited in the background, - /// and cancellation is supported via the observable's subscription. + /// A faulted or cancelled task terminates the sequence with that error. Since the task is a single + /// shared instance, every subscriber observes the same outcome. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "The suffix names the asynchronous signal the method returns, not asynchronous work.")] public IObservableAsync ToAsyncSignal() => new TaskResultSignal(task); } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/AnyAllAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/AnyAllAsync.cs index 009d9153..76a673b8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/AnyAllAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/AnyAllAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of extension methods for working with asynchronous observable sequences. -/// The methods in this class enable querying and evaluating asynchronous observable sequences, such as -/// determining whether any or all elements satisfy a condition. These methods are designed to be used with types that -/// implement asynchronous observation patterns. public static partial class SignalAsyncExtensions { /// Asynchronous quantifier operators that evaluate elements of an observable source sequence. @@ -53,7 +50,7 @@ public ValueTask AnyAsync(CancellationToken cancellationToken) => /// A task that represents the asynchronous operation. The task result contains if every /// element of the sequence passes the test in the specified predicate, or if the sequence is empty; otherwise, /// . - /// Thrown if is . + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask AllAsync(Func predicate) => source.AllAsync(predicate, CancellationToken.None); @@ -64,7 +61,7 @@ public ValueTask AnyAsync(CancellationToken cancellationToken) => /// A task that represents the asynchronous operation. The task result contains if every /// element of the sequence passes the test in the specified predicate, or if the sequence is empty; otherwise, /// . - /// Thrown if is . + /// is . public async ValueTask AllAsync(Func predicate, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(predicate); @@ -105,7 +102,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => /// A cancellation token for the operation. internal sealed class AllTaskWitness(Func predicate, CancellationToken cancellationToken) : TaskResultWitnessAsyncBase(cancellationToken) { - /// The predicate function used to test each element in the sequence. + /// The test applied to every element. private readonly Func _predicate = predicate; /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs index 101a82e4..f196f0d5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs @@ -9,11 +9,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of static methods for composing and merging asynchronous observable sequences. -/// The SignalAsync class offers extension methods that enable advanced composition patterns for -/// asynchronous observables, such as merging multiple sequences into a single stream. These methods are designed to -/// work with the SignalAsync{T} abstraction, supporting scenarios where asynchronous event streams need to be -/// combined or coordinated. All methods are thread-safe and intended for use in asynchronous, reactive programming -/// models. public static partial class SignalAsyncExtensions { /// Blend/Merge operators for an enumerable collection of observable source sequences. @@ -26,10 +21,8 @@ public static partial class SignalAsyncExtensions /// source sequences as they arrive. /// /// An observable sequence that emits items from all input sequences as they are produced. - /// The resulting observable sequence emits items from all source sequences in the order they - /// arrive, interleaving emissions if sources produce items concurrently. The merged sequence completes when all - /// source sequences have completed. If any source sequence signals an error, the merged sequence will propagate - /// that error and terminate. + /// Emissions interleave as the sources produce them; the result completes once every source has + /// completed, and an error from any source propagates and terminates it. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Blend() => new BlendEnumerableSignal(sources); @@ -55,9 +48,8 @@ public IObservableAsync Merge() => /// inner sequences as they arrive. /// /// An asynchronous observable sequence that emits items from all inner observable sequences as they are produced. - /// The resulting sequence emits items from all inner sequences concurrently as they become - /// available. The merged sequence completes when the source sequence and all inner sequences have completed. If any - /// inner sequence signals an error, the merged sequence will propagate that error and terminate. + /// Every inner sequence stays subscribed at once; the result completes only after the outer sequence + /// and all inner sequences have completed, and an error from any of them propagates and terminates it. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Blend() => new BlendSignalSourcesSignal(source); @@ -90,9 +82,8 @@ public IObservableAsync Merge(int maxConcurrent) => /// Combines the elements of two asynchronous observable sequences into a single sequence by merging their emissions. /// The second asynchronous observable sequence to merge with the first. /// An SignalAsync{T} that emits the elements from both input sequences as they arrive. - /// The resulting sequence emits items from both source sequences in the order they are produced. - /// The merged sequence completes when both input sequences have completed. If either source sequence signals an - /// error, the merged sequence will propagate that error and terminate. + /// The result completes once both sequences have completed; an error from either propagates and + /// terminates it. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Blend(IObservableAsync other) => new BlendEnumerableSignal([source, other]); @@ -239,22 +230,16 @@ 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. - /// + /// Re-checks the disposed flag inside the serialization gate and forwards the value downstream if the + /// subscription is alive. /// The value to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayNextIfActiveAsync(T value) => DisposalHelper.HasDisposed(_disposed) ? default : _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. - /// + /// Re-checks the disposed flag inside the serialization gate and forwards the error downstream if the + /// subscription is alive. /// The error to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayErrorIfActiveAsync(Exception exception) => DisposalHelper.HasDisposed(_disposed) @@ -445,15 +430,10 @@ internal override BlendBranchWitness CreateBranchObserver() => /// The parent bounded merge coordinator whose semaphore slot is released on disposal. internal sealed class BlendBranchWitnessWithPermit(BoundedBlendCoordinator parent) : BlendBranchWitness(parent) { - /// Tracks whether the semaphore slot has already been released for this witness. - /// - /// can be invoked more than once for the same witness - /// (auto-dispose after OnCompletedAsync, then again from CompositeDisposableAsync.Remove - /// and from the parent's FinishAsync path). Without this guard, - /// would be called multiple times per witness, exceeding maxCount and throwing - /// — which interrupts the parent's completion chain and leaves - /// downstream witnesses waiting forever. - /// + /// Guards the one-time release of this witness's semaphore slot. + /// can run several times for one witness, and a repeat + /// would exceed the semaphore's count and throw + /// , breaking the parent's completion chain. private int _released; /// @@ -498,7 +478,7 @@ internal sealed class BlendSequenceCoordinator : IAsyncDisposable /// Cancellation source for disposal. private readonly CancellationTokenSource _cts = new(); - /// A cached token from used to link with per-emission tokens. + /// Token signalled when this subscription is disposed. private readonly CancellationToken _disposedCancellationToken; /// Serializes observer notifications to prevent concurrent calls. @@ -508,7 +488,8 @@ internal sealed class BlendSequenceCoordinator : IAsyncDisposable private readonly TaskCompletionSource _subscriptionFinished = new(TaskCreationOptions.RunContinuationsAsynchronously); - /// Tracks reentrant calls to prevent deadlocks. + /// Marks the subscribing loop's own async flow so that a raised from + /// inside it does not wait on and deadlock. private readonly AsyncLocal _reentrant = new(); /// The downstream observer. @@ -538,11 +519,8 @@ public BlendSequenceCoordinator(IObserverAsync observer, IEnumerable FinishAsync(null); - /// - /// Routes an exception from a post-disposal completion result to the unhandled exception handler. - /// Called when returns true (already disposed) - /// and the completion result carries an exception. - /// + /// Routes the exception of a completion result that arrives after disposal to the unhandled + /// exception handler. /// The completion result, or null if disposing without signaling. internal static void RoutePostDisposalException(Result? result) { @@ -559,7 +537,7 @@ internal static void RoutePostDisposalException(Result? result) [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "Method already named with Async")] + Justification = "Fire-and-forget launcher; the asynchronous work is the lambda it starts.")] internal void BeginSubscribing() => FireAndForgetHelper.Run(async () => { _reentrant.Value = true; @@ -649,11 +627,8 @@ internal async ValueTask RelayNextAsync(T value, CancellationToken token) } } - /// - /// 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. - /// + /// Re-checks the disposed flag inside the serialization gate and forwards the value downstream if + /// the subscription is alive. /// The value to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayNextIfActiveAsync(T value) => DisposalHelper.HasDisposed(_disposed) @@ -678,11 +653,8 @@ internal async ValueTask RelayErrorAsync(Exception ex, CancellationToken token) } } - /// - /// 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. - /// + /// Re-checks the disposed flag inside the serialization gate and forwards the error downstream if + /// the subscription is alive. /// The error to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayErrorIfActiveAsync(Exception ex) => DisposalHelper.HasDisposed(_disposed) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Cast.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Cast.cs index 800000d9..7da2eb4d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Cast.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Cast.cs @@ -7,10 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static methods that extend the functionality of asynchronous -/// observables, enabling additional operations such as type casting and sequence manipulation. These methods are -/// intended to be used with the SignalAsync{T} type to facilitate reactive programming patterns in asynchronous -/// scenarios. public static partial class SignalAsyncExtensions { /// Type-casting operators for an observable source sequence. @@ -22,13 +18,12 @@ public static partial class SignalAsyncExtensions /// The type to which the elements of the sequence are cast. /// An observable sequence whose elements are the result of casting each element of the source sequence to /// . - /// If an element in the source sequence cannot be cast to , the sequence completes with a failure containing the exception. This method is useful for - /// working with sequences of objects when the actual element type is known at runtime. + /// A failed cast does not throw at the call site: the sequence completes with a failure carrying the + /// cast exception. [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public extension API — caller specifies TResult explicitly: source.Cast().")] + Justification = "The caller chooses TResult; no parameter carries it.")] public IObservableAsync Cast() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -47,7 +42,7 @@ public IObservableAsync Cast() [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Deliberate lack of type inference.")] + Justification = "The caller chooses TResult; no parameter carries it.")] public IObservableAsync CastTo() { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs index 576d55ee..b09f9e00 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for composing and concatenating asynchronous observable sequences. -/// The methods in this class enable fluent composition of asynchronous observables, allowing multiple -/// sequences to be combined into a single sequence that emits items in order. These methods are intended for use with -/// types that implement asynchronous, push-based data streams. public static partial class SignalAsyncExtensions { /// Chain/Concat operators for a collection of observable sequences. @@ -23,8 +20,8 @@ public static partial class SignalAsyncExtensions /// /// An asynchronous observable sequence that emits all items from each source sequence in the order they appear in /// the collection. - /// Each source sequence is subscribed to only after the previous one completes. If any source - /// sequence signals an error, concatenation stops and the error is propagated to the observer. + /// A source is subscribed only after the previous one completes, so at most one source is active at a + /// time; an error from any source stops the concatenation and is propagated. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Chain() => new ChainEnumerableSignal(sources); @@ -51,9 +48,8 @@ public IObservableAsync Concat() => /// /// An observable sequence that emits the elements of each inner observable sequence in order, waiting for each to /// complete before subscribing to the next. - /// If any inner observable sequence signals an error, the resulting sequence will propagate that - /// error and terminate immediately. The concatenation is performed in a deferred and sequential manner, ensuring - /// that only one inner sequence is active at a time. + /// At most one inner sequence is subscribed at a time; an error from any of them terminates the result + /// immediately. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Chain() => new ChainSignalSourcesSignal(source); @@ -80,9 +76,8 @@ public IObservableAsync Concat() => /// The second observable sequence to concatenate. Cannot be null. /// An observable sequence that emits all elements from the first sequence, followed by all elements from the second /// sequence. - /// The resulting sequence emits all items from the first observable before subscribing to and - /// emitting items from the second observable. If either sequence signals an error, the concatenation terminates and - /// the error is propagated to observers. + /// is not subscribed until the first sequence completes; an error from + /// either terminates the result. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Chain(IObservableAsync second) => new ChainEnumerableSignal([source, second]); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs index fb415ddc..f18e70cd 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs @@ -14,17 +14,14 @@ namespace ReactiveUI.Primitives.Async; /// The type of elements produced by the concatenated observable sequences. /// A collection of asynchronous signals to be concatenated. Each signal is subscribed to sequentially; the next /// begins only after the previous completes. -/// This class enables sequential composition of multiple asynchronous observables, ensuring that items -/// from each source are emitted in order and that subsequent observables are not subscribed to until the preceding one -/// has completed. If any observable in the sequence signals an error, the concatenation terminates and the error is -/// propagated to the observer. +/// An error from any signal terminates the concatenation and is propagated to the observer. [System.Diagnostics.DebuggerDisplay("ChainEnumerableSignal: Signals = {_signals}")] public sealed class ChainEnumerableSignal(IEnumerable> signals) : IObservableAsync { /// The enumerable collection of signal sequences to concatenate. private readonly IEnumerable> _signals = signals; - /// Subscribes the specified observer by creating a that iterates through the enumerable of observables sequentially. + /// Subscribes the observer to a that walks the signals one at a time. /// The observer to receive elements from the concatenated sequences. /// A token to cancel the subscription. /// An async disposable that tears down the subscription when disposed. @@ -53,7 +50,7 @@ internal sealed class ChainSequenceCoordinator : IAsyncDisposable /// Cancellation token source used to signal disposal of the subscription. private readonly CancellationTokenSource _cts = new(); - /// Cached cancellation token from the dispose cancellation token source. + /// Token signalled when this subscription is disposed. private readonly CancellationToken _disposedCancellationToken; /// The downstream observer to forward elements to. @@ -76,8 +73,8 @@ public ChainSequenceCoordinator(ChainEnumerableSignal parent, IObserverAsync< [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask DisposeAsync() => FinishAsync(null); - /// Handles a second call to when already disposed, routing any failure exception to the unhandled exception handler. - /// The completion result from the second call. + /// Routes the failure carried by a redundant call to the unhandled exception handler. + /// The completion result from the redundant call. internal static void HandleAlreadyDisposed(Result? result) { if (result?.Exception is not { } exception) @@ -121,11 +118,9 @@ internal async ValueTask SubscribeNextSignalAsync() /// A task representing the asynchronous operation. internal ValueTask RelayInnerErrorAsync(Exception exception, CancellationToken cancellationToken) { - // The inner subscription is rooted in _disposedCancellationToken (see SubscribeNextSignalAsync), - // so its disposal already cascades into the inner observer's own cancellation. Forwarding - // _disposedCancellationToken directly preserves the cancellation semantics that a linked - // CTS would have provided, without the per-emission Linked2CancellationTokenSource alloc - // that dominated the GC profile. + // The inner subscription is rooted in _disposedCancellationToken (see SubscribeNextSignalAsync), so + // disposing it cascades into the inner observer's own cancellation. Forwarding that token directly + // gives the same cancellation semantics as a linked CTS without allocating one per emission. _ = cancellationToken; return _observer.OnErrorResumeAsync(exception, _disposedCancellationToken); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs index 8ffc1995..292eeee3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs @@ -42,7 +42,7 @@ internal sealed class ChainCoordinator : IAsyncDisposable /// Cancellation token source used to signal disposal of the subscription. private readonly CancellationTokenSource _disposeCts = new(); - /// Cached cancellation token from the dispose cancellation token source. + /// Token signalled when this subscription is disposed. private readonly CancellationToken _disposedCancellationToken; /// Disposable that holds the single outer subscription. @@ -75,8 +75,8 @@ public ChainCoordinator(IObserverAsync observer) [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask DisposeAsync() => FinishAsync(null); - /// Handles a second call to when already disposed, routing any failure exception to the unhandled exception handler. - /// The completion result from the second call. + /// Routes the failure carried by a redundant call to the unhandled exception handler. + /// The completion result from the redundant call. internal static void HandleAlreadyDisposed(Result? result) { if (result?.Exception is not { } exception) @@ -237,10 +237,9 @@ protected override async ValueTask OnErrorResumeAsyncCore( Exception error, CancellationToken cancellationToken) { - // The outer subscription is rooted in _disposedCancellationToken, so its disposal - // already cascades into this observer's cancellation. Forwarding the dispose token - // directly preserves the cancellation semantics that a linked CTS would have - // provided, without the per-emission Linked2CancellationTokenSource alloc. + // The outer subscription is rooted in _disposedCancellationToken, so disposing it cascades + // into this observer's cancellation. Forwarding the dispose token directly gives the same + // cancellation semantics as a linked CTS without allocating one per emission. _ = cancellationToken; var token = subscription._disposedCancellationToken; using (await subscription._observerOnSomethingGate.EnterAsync(token).ConfigureAwait(false)) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs index d3015c6a..a969cde6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs @@ -11,7 +11,5 @@ namespace ReactiveUI.Primitives.Async; /// An action to be invoked with a when the completion event occurs. This callback is called to /// notify the subscriber of the completion result. /// An that unsubscribes the notification when disposed asynchronously. -/// The returned should be disposed to stop receiving completion -/// notifications and to release any associated resources. The callback may be invoked on -/// a background thread. +/// may be invoked on a background thread. public delegate IAsyncDisposable CompletionSignalDelegate(Action notifyStop); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ContainsAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ContainsAsync.cs index 6c821ffd..01ebddfe 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ContainsAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ContainsAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of extension methods for working with asynchronous observable sequences. -/// The methods in this class enable querying and manipulation of asynchronous observables, such as -/// determining whether a sequence contains a specified element. These extensions are designed to integrate with the -/// SignalAsync{T} pattern for asynchronous, push-based data streams. public static partial class SignalAsyncExtensions { /// Asynchronous containment operators for an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/CountAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/CountAsync.cs index 2a9d6694..c428ff2b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/CountAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/CountAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for performing asynchronous operations on observable sequences. -/// The SignalAsync class contains static methods that extend the functionality of asynchronous -/// observable sequences, enabling operations such as counting elements that satisfy a specified condition. These -/// methods are designed to work with types that implement asynchronous observation patterns. public static partial class SignalAsyncExtensions { /// Asynchronous element-counting operators for an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs index 5cd2c7b5..64784606 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs @@ -5,10 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of static methods for creating and composing asynchronous observable sequences. -/// The SignalAsync class offers extension methods that enable functional-style operations, such as -/// filtering for distinct elements, on asynchronous observable sequences. These methods are designed to work with the -/// SignalAsync{T} type, allowing developers to build complex, asynchronous event processing pipelines in a -/// composable manner. public static partial class SignalAsyncExtensions { /// Distinctness operators for an observable source sequence. @@ -21,8 +17,8 @@ public static partial class SignalAsyncExtensions /// comparer for the element type. /// /// An observable sequence that contains distinct elements from the source sequence. - /// Elements are considered distinct based on the default equality comparer for type T. - /// The order of elements is preserved. + /// Only the first occurrence of each element reaches observers, in source order; the set of seen + /// elements lives for the whole subscription. public IObservableAsync Distinct() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -38,8 +34,8 @@ public IObservableAsync Distinct() /// used. /// An observable sequence that emits each distinct element from the source sequence, in the order in which they /// are received. - /// Only the first occurrence of each element, as determined by the specified equality - /// comparer, is emitted to observers. Subsequent duplicate elements are ignored. + /// Only the first occurrence of each element, as judged by , + /// reaches observers. public IObservableAsync Distinct(IEqualityComparer equalityComparer) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -56,8 +52,7 @@ public IObservableAsync Distinct(IEqualityComparer equalityComparer) /// A function to extract the key for each element. Cannot be null. /// An observable sequence that contains only the first occurrence of each distinct key as determined by the key /// selector. - /// Elements are considered distinct based on the value returned by the key selector and - /// the default equality comparer for the key type. + /// Keys are compared with the default equality comparer for . public IObservableAsync DistinctBy(Func keySelector) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -75,10 +70,7 @@ public IObservableAsync DistinctBy(Func keySelector) /// An equality comparer to compare keys for equality. Cannot be null. /// An observable sequence that contains only the first occurrence of each distinct key as determined by the /// specified key selector and equality comparer. - /// Thrown if or is null. - /// Elements are considered distinct based on the value returned by the function and compared using the provided . Only the - /// first occurrence of each key is included in the resulting sequence. + /// or is . public IObservableAsync DistinctBy( Func keySelector, IEqualityComparer equalityComparer) @@ -123,7 +115,7 @@ internal sealed class DistinctWitness( IEqualityComparer comparer, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// Set of previously-forwarded values; returns for duplicates. + /// The values forwarded so far; returns for a duplicate. private readonly HashSet _seen = [with(comparer)]; /// @@ -179,7 +171,7 @@ internal sealed class DistinctByWitness( IEqualityComparer comparer, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// Set of previously-seen keys. + /// The keys seen so far. private readonly HashSet _seen = [with(comparer)]; /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs index 50ec7a5a..177d980f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides Timeout extension methods for asynchronous observable sequences. -/// Timeout applies a time limit to the observable sequence. If the sequence does not produce -/// a value within the specified time span, a is signalled as a failure -/// completion. public static partial class SignalAsyncExtensions { /// Timeout operators for an observable source sequence. @@ -18,13 +15,13 @@ public static partial class SignalAsyncExtensions extension(IObservableAsync source) { /// - /// Applies a dueTime policy to the observable sequence. If the next element is not received within - /// the specified time span, the sequence completes with a . + /// Applies a time limit between elements. If the next element does not arrive within + /// , the sequence completes with a . /// /// The maximum time span allowed between consecutive elements. Must be positive. /// An observable sequence that mirrors the source but completes with a - /// if any inter-element interval exceeds the specified dueTime. - /// Thrown if is negative or zero. + /// once an inter-element gap exceeds . + /// is negative or zero. public IObservableAsync Expire(TimeSpan dueTime) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThanOrEqual(dueTime, TimeSpan.Zero); @@ -35,15 +32,15 @@ public IObservableAsync Expire(TimeSpan dueTime) /// /// Async observable that mirrors the source but completes with a - /// if any inter-element interval exceeds the specified dueTime. + /// once an inter-element gap exceeds the configured interval. /// /// The type of elements in the sequence. /// The source observable sequence. /// The maximum allowed inter-element interval. - /// The time provider used for scheduling the dueTime. + /// The time provider used to schedule the deadline. internal sealed class TimeoutSignal(IObservableAsync source, TimeSpan dueTime, TimeProvider timeProvider) : IObservableAsync { - /// Subscribes the specified observer and starts the dueTime timer. + /// Subscribes the specified observer and starts the deadline timer. /// The observer to receive elements from the source. /// A token to cancel the subscription. /// An async disposable that tears down the subscription when disposed. @@ -59,30 +56,27 @@ async ValueTask IObservableAsync.SubscribeAsync( /// /// Observer that resets a timer on each received element and signals a - /// if no element arrives within the configured dueTime. + /// if no element arrives within the configured interval. /// /// The downstream observer to forward elements to. /// The maximum allowed inter-element interval. - /// The time provider used for scheduling the dueTime. + /// The time provider used to schedule the deadline. internal sealed class TimeoutWitness(IObserverAsync observer, TimeSpan dueTime, TimeProvider timeProvider) : WitnessAsync { /// Synchronization gate protecting timer state. private readonly Lock _gate = new(); /// - /// Single pre-allocated timer rearmed via - /// on every emission. Replaces the previous per-emission fire-and-forget - /// OnTimeoutAsync task; the per-emission allocations (linked CTS, async state - /// machine box for OnTimeoutAsync, Task.Delay's TimerQueueTimer) collapse to a single - /// Change call which is highly optimised in the BCL. + /// The one timer for this subscription, rearmed via on + /// every emission so that tracking the deadline costs no per-emission allocation. /// private ITimer? _timer; - /// Indicates whether the observer has already received a completion signal; ignored timeouts once true. + /// Set once the observer has been terminated; suppresses any later timeout signal. private bool _completed; - /// Allocates the timer and schedules the first dueTime tick. - /// Cancellation token; unused after the redesign but kept for API compatibility. + /// Allocates the timer and schedules the first deadline tick. + /// Unused; the timer carries its own deadline. internal void StartTimer(CancellationToken cancellationToken) { _ = cancellationToken; @@ -96,15 +90,14 @@ internal void StartTimer(CancellationToken cancellationToken) } catch (Exception e) { - // Preserve the legacy contract: CreateTimer failures route to the unhandled - // exception handler rather than tearing down the subscription. Without a timer - // the operator degrades to a pass-through; downstream callers continue to - // receive emissions without a timeout signal. + // A CreateTimer failure routes to the unhandled exception handler rather than tearing + // down the subscription: with no timer the operator degrades to a pass-through that + // forwards every emission and never signals a timeout. UnhandledExceptionHandler.ReportUnhandledException(e); } } - /// Rearms the dueTime timer and forwards the element to the downstream observer. + /// Rearms the deadline timer and forwards the element to the downstream observer. /// The element to forward. /// A token to cancel the operation. /// A task representing the asynchronous operation. @@ -114,7 +107,7 @@ protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancella return observer.OnNextAsync(value, cancellationToken); } - /// Stops the dueTime timer and forwards the error to the downstream observer. + /// Stops the deadline timer and forwards the error to the downstream observer. /// The error to forward. /// A token to cancel the operation. /// A task representing the asynchronous operation. @@ -129,7 +122,7 @@ protected override ValueTask OnErrorResumeAsyncCore(Exception error, Cancellatio return observer.OnErrorResumeAsync(error, cancellationToken); } - /// Stops the dueTime timer and forwards completion to the downstream observer. + /// Stops the deadline timer and forwards completion to the downstream observer. /// The completion result. /// A task representing the asynchronous operation. protected override ValueTask OnCompletedAsyncCore(Result result) @@ -143,7 +136,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) return observer.OnCompletedAsync(result); } - /// Disposes the dueTime timer during teardown. + /// Disposes the deadline timer during teardown. /// A completed task. protected override async ValueTask DisposeAsyncCore() { @@ -176,7 +169,7 @@ private static async Task FireTimeoutAsync(IObserverAsync target) } } - /// Timer callback: signals the downstream observer with a completion unless the observer has already terminated. + /// Timer callback: completes the downstream observer with a unless it has terminated. private void OnTimerFired() { lock (_gate) @@ -192,18 +185,15 @@ private void OnTimerFired() _ = FireTimeoutAsync(observer); } - /// Rearms the timeout deadline for the next emission. Isolated from - /// coverage because the _timer is null branch is only reachable when the - /// source emits after the sink's DisposeAsyncCore has nulled the timer — - /// a race the single-threaded test harness cannot deterministically trigger. + /// Rearms the timeout deadline for the next emission; _timer is null only when the source + /// emits after DisposeAsyncCore has torn the timer down, a race no deterministic test can hit. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void RearmTimer() => _timer?.Change(dueTime, System.Threading.Timeout.InfiniteTimeSpan); - /// Stops the timeout deadline on terminal forwarding. Isolated from - /// coverage because the _timer is null branch is only reachable under the - /// same source-after-Dispose race that guards against. + /// Stops the timeout deadline on terminal forwarding; _timer is null only under the same + /// post-teardown race as . [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void StopTimer() => @@ -213,20 +203,20 @@ private void StopTimer() => /// /// Async observable that mirrors the source but switches to a fallback observable - /// if any inter-element interval exceeds the specified dueTime. + /// once an inter-element gap exceeds the configured interval. /// /// The type of elements in the sequence. /// The source observable sequence. /// The maximum allowed inter-element interval. - /// The fallback observable to switch to on dueTime. - /// The time provider used for scheduling the dueTime. + /// The observable to switch to when the interval elapses. + /// The time provider used to schedule the deadline. internal sealed class TimeoutWithFallbackSignal( IObservableAsync source, TimeSpan dueTime, IObservableAsync fallback, TimeProvider timeProvider) : IObservableAsync { - /// Subscribes the specified observer by wrapping the source with a dueTime and a catch-to-fallback. + /// Subscribes the specified observer by wrapping the source with a deadline and a catch-to-fallback. /// The observer to receive elements. /// A token to cancel the subscription. /// An async disposable that tears down the subscription when disposed. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/FirstAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/FirstAsync.cs index 742bd4c5..a2a8cfdd 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/FirstAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/FirstAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The methods in this class enable querying and manipulation of asynchronous observables, such as -/// retrieving the first element that matches a specified condition. These extensions are designed to be used with types -/// that implement asynchronous observable patterns. public static partial class SignalAsyncExtensions { /// First-element operators for an observable source sequence. @@ -22,6 +19,7 @@ public static partial class SignalAsyncExtensions /// predicate returns . /// A task that represents the asynchronous operation. The task result contains the first element that matches /// the predicate. + /// The sequence completes without a matching element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask FirstAsync(Func predicate) => source.FirstAsync(predicate, CancellationToken.None); @@ -32,6 +30,7 @@ public ValueTask FirstAsync(Func predicate) => /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the first element that matches /// the predicate. + /// The sequence completes without a matching element. public async ValueTask FirstAsync(Func predicate, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -44,8 +43,7 @@ public async ValueTask FirstAsync(Func predicate, CancellationToken /// Asynchronously returns the first element of the sequence. /// A task that represents the asynchronous operation. The task result contains the first element of the /// sequence. - /// If the sequence is empty, the behavior depends on the implementation and may result - /// in an exception being thrown. + /// The sequence completes without producing an element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask FirstAsync() => source.FirstAsync(CancellationToken.None); @@ -54,8 +52,7 @@ public ValueTask FirstAsync() => /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the first element of the /// sequence. - /// If the sequence is empty, the behavior depends on the implementation and may result - /// in an exception being thrown. + /// The sequence completes without producing an element. public async ValueTask FirstAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs index f3b3626f..8e67ae98 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs @@ -7,10 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The methods in this class enable querying and retrieving elements from asynchronous observables, such -/// as obtaining the first element that matches a condition or a default value if no such element exists. These -/// extensions are designed to be used with types implementing asynchronous observable patterns and support cancellation -/// via cancellation tokens. public static partial class SignalAsyncExtensions { /// First-or-default operators for an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs index 364877a2..338cacaa 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs @@ -5,9 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides SelectMany (flat map) extension methods for asynchronous observable sequences. -/// SelectMany projects each element of an observable sequence to an observable sequence and -/// merges the resulting observable sequences into one observable sequence. This is the monadic bind -/// operation for observables and is essential for composing chains of asynchronous operations. public static partial class SignalAsyncExtensions { /// FlatMap/SelectMany operators for an observable source sequence. @@ -24,7 +21,7 @@ public static partial class SignalAsyncExtensions /// for each element. /// An observable sequence whose elements are the result of invoking the one-to-many transform /// function on each element of the source sequence and merging the results. - /// Thrown if is null. + /// is . public IObservableAsync FlatMap(Func> selector) { ArgumentExceptionHelper.ThrowIfNull(selector); @@ -41,7 +38,7 @@ public IObservableAsync FlatMap(Func /// An observable sequence whose elements are the result of invoking the one-to-many transform /// function on each element of the source sequence and merging the results. - /// Thrown if is null. + /// is . public IObservableAsync FlatMap( Func>> selector) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs index d64f0296..a445cb75 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs @@ -8,10 +8,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for asynchronous observable sequences, enabling functional operations such as scanning /// and accumulation over streamed data. /// -/// The methods in this class allow developers to perform stateful transformations and aggregations on -/// asynchronous observables. These operations are useful for scenarios where intermediate results or running totals are -/// needed as items are received. All methods are designed to work with asynchronous patterns and support cancellation -/// via tokens. public static partial class SignalAsyncExtensions { /// Fold/Scan (running accumulation) operators for an observable source sequence. @@ -28,7 +24,7 @@ public static partial class SignalAsyncExtensions /// An asynchronous accumulator function to be invoked on each element. Receives the current accumulator value, /// the current element, and a cancellation token. /// An observable sequence containing the accumulated values produced after each element is processed. - /// Thrown if is null. + /// is . public IObservableAsync Fold( TAcc seed, Func> accumulator) @@ -44,7 +40,7 @@ public IObservableAsync Fold( /// An accumulator function to be invoked on each element. Receives the current accumulator value and the /// current element. /// An observable sequence containing the accumulated values produced after each element is processed. - /// Thrown if is null. + /// is . public IObservableAsync Fold(TAcc seed, Func accumulator) { ArgumentExceptionHelper.ThrowIfNull(accumulator); @@ -54,9 +50,8 @@ public IObservableAsync Fold(TAcc seed, Func accumula } /// - /// Async-accumulator variant of . - /// Allocates one observable wrapper and one sealed observer per subscription — no per-emission closure or - /// state-machine box from the previous Create<TAcc>((observer, token) => ...) pattern. + /// Async-accumulator variant of , + /// allocating one observer per subscription and nothing per emission. /// /// The element type of the source sequence. /// The accumulator type. @@ -136,9 +131,8 @@ private async ValueTask AwaitAndForwardAsync(ValueTask pending, Cancellati } /// - /// Synchronous-accumulator variant of . Same - /// allocation profile as but the per-emission OnNextAsyncCore is - /// sync-completed when the downstream completes synchronously. + /// Synchronous-accumulator variant of , whose + /// per-emission path completes synchronously whenever the downstream does. /// /// The element type of the source sequence. /// The accumulator type. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ForEachAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ForEachAsync.cs index 5e760566..cbc45811 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ForEachAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ForEachAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains utility methods that enable consumers to process items emitted by -/// asynchronous observables in a convenient and idiomatic way. These methods are designed to simplify common patterns -/// when interacting with IAsyncObservable or similar asynchronous push-based data sources. public static partial class SignalAsyncExtensions { /// Asynchronous per-element iteration operators for an observable source sequence. @@ -22,9 +19,9 @@ public static partial class SignalAsyncExtensions /// token, and returns a ValueTask that completes when processing is finished. /// A ValueTask that represents the asynchronous operation. The task completes when all elements have been /// processed or the operation is canceled. - /// If the sequence completes or is canceled, the method returns when all in-flight - /// actions have finished. Exceptions thrown by the action or during enumeration will propagate to the returned - /// task. + /// is . + /// The returned task completes once the sequence terminates and the last invocation has finished; an + /// exception from or from the sequence surfaces on it. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ForEachAsync(Func onNextAsync) => source.ForEachAsync(onNextAsync, CancellationToken.None); @@ -36,9 +33,9 @@ public ValueTask ForEachAsync(Func onNextAsync) /// signaled. /// A ValueTask that represents the asynchronous operation. The task completes when all elements have been /// processed or the operation is canceled. - /// If the sequence completes or is canceled, the method returns when all in-flight - /// actions have finished. Exceptions thrown by the action or during enumeration will propagate to the returned - /// task. + /// is . + /// The returned task completes once the sequence terminates and the last invocation has finished; an + /// exception from or from the sequence surfaces on it. public async ValueTask ForEachAsync( Func onNextAsync, CancellationToken cancellationToken) @@ -55,7 +52,7 @@ public async ValueTask ForEachAsync( /// The action to invoke for each element in the sequence. Cannot be null. /// A task that represents the asynchronous iteration operation. The task completes when the sequence has been /// fully processed or the operation is canceled. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ForEachAsync(Action onNext) => source.ForEachAsync(onNext, CancellationToken.None); @@ -65,7 +62,7 @@ public ValueTask ForEachAsync(Action onNext) => /// A cancellation token that can be used to cancel the iteration. /// A task that represents the asynchronous iteration operation. The task completes when the sequence has been /// fully processed or the operation is canceled. - /// Thrown if is null. + /// is . public async ValueTask ForEachAsync(Action onNext, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(onNext); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs index 5067d2f7..a077a8a2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs @@ -10,9 +10,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for creating and manipulating asynchronous observable sequences. -/// The methods in this class enable advanced operations on asynchronous observables, such as grouping -/// elements by key. These extensions are intended for use with types implementing asynchronous observation patterns, -/// allowing developers to compose and transform streams of data in a reactive manner. public static partial class SignalAsyncExtensions { /// Grouping operators for an observable source sequence. @@ -24,10 +21,9 @@ public static partial class SignalAsyncExtensions /// The type of the key returned by the key selector function. Must be non-nullable. /// A function to extract the key for each element in the source sequence. /// An asynchronous observable sequence of grouped observables, each containing elements that share a common key. - /// Thrown if is null. - /// Each group in the resulting sequence corresponds to a unique key produced by the key - /// selector. The groups are emitted as soon as their first element is encountered in the source sequence. The - /// returned grouped observables can be subscribed to independently. + /// is . + /// A group is emitted when its first element arrives, and each grouped observable can be subscribed + /// independently. public IObservableAsync> GroupBy(Func keySelector) where TKey : notnull { @@ -50,11 +46,9 @@ public IObservableAsync> GroupBy(Func /// An asynchronous observable sequence containing grouped observables, each representing a collection of elements /// that share a common key. - /// Thrown if is null. - /// Each group in the resulting sequence is represented by a , which exposes the group's key and an observable sequence of its - /// elements. The parameter allows customization of the signal used for - /// each group, which can affect how elements are buffered or multicast within the group. + /// is . + /// The signal returned by decides how a group's elements are + /// buffered or multicast to its subscribers. public IObservableAsync> GroupBy( Func keySelector, Func> groupSignalSelector) @@ -136,7 +130,6 @@ protected override async ValueTask OnNextAsyncCore(TValue value, CancellationTok signal = parent._groupSignalSelector(key); _signalsByKey.Add(key, signal); - // We use the cancellationToken passed from the source subscription. await observer.OnNextAsync( new( key, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/GroupedAsyncSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/GroupedAsyncSignal.cs index 4c26b7d5..598ecbe8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/GroupedAsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/GroupedAsyncSignal.cs @@ -10,8 +10,6 @@ namespace ReactiveUI.Primitives.Async; /// Represents an asynchronous observable sequence that is grouped by a specified key. /// The type of the key used to group elements in the observable sequence. /// The type of the elements contained in the grouped observable sequence. -/// Each instance corresponds to a group within the parent observable, identified by its key. Observers -/// can subscribe to receive elements belonging to the group associated with the specified key. [System.Diagnostics.DebuggerDisplay("GroupedAsyncSignal: Key = {Key}")] public sealed class GroupedAsyncSignal : IObservableAsync { @@ -27,7 +25,7 @@ internal GroupedAsyncSignal( CancellationToken parentDisposedToken) => State = new(key, signalValues, disposables, parentDisposedToken); - /// Gets the key associated with the current object. + /// Gets the key shared by every element in this group. public TKey Key => State.Key; /// Gets the state shared by this grouped observable and its helper operations. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs index 377f838e..9f381fad 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs @@ -7,10 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for creating and manipulating asynchronous observable sequences. -/// The SignalAsync class offers LINQ-style operators for working with asynchronous observables, -/// enabling developers to compose, filter, and transform event streams in an asynchronous context. These methods are -/// designed to integrate with the SignalAsync{T} type, supporting both synchronous and asynchronous predicate -/// functions for filtering sequences. public static partial class SignalAsyncExtensions { /// Filtering (Keep/Where) operators for an observable source sequence. @@ -27,10 +23,8 @@ public static partial class SignalAsyncExtensions /// langword="false"/>. /// An observable sequence that emits only those elements for which the predicate returns . - /// The predicate is invoked asynchronously for each element as it is observed. If the - /// predicate throws an exception or the ValueTask is faulted, the resulting sequence will propagate the error - /// to its observers. The cancellation token provided to the predicate can be used to observe cancellation - /// requests during predicate evaluation. + /// An exception thrown by , or a faulted task from it, propagates to the + /// observers of the resulting sequence. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Keep(Func> predicate) => new KeepAsyncSignal(source, predicate); @@ -43,9 +37,6 @@ public IObservableAsync Keep(Func> pred /// function returns . /// An observable sequence that contains elements from the current sequence that satisfy the specified /// predicate. - /// The resulting observable emits only those elements for which the returns . The order and timing of element emission are preserved - /// from the original sequence. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Keep(Func predicate) => new KeepSyncSignal(source, predicate); @@ -55,6 +46,7 @@ public IObservableAsync Keep(Func predicate) => /// The caller-supplied state passed to the predicate. /// The predicate that values and the state must satisfy. /// An observable sequence of values that satisfy the predicate. + /// is . public IObservableAsync KeepWith( TState state, Func predicate) @@ -86,9 +78,8 @@ public IObservableAsync Where(Func predicate) => } /// - /// Async-predicate variant of . - /// Allocates one observable wrapper and one sealed observer per subscription — no per-emission closure or - /// state-machine box from the previous Create<T>((observer, token) => ...) pattern. + /// Async-predicate variant of , + /// allocating one observer per subscription and nothing per emission. /// /// The element type of the source sequence. /// The source observable. @@ -145,9 +136,8 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } /// - /// Synchronous-predicate variant of . Same allocation - /// profile as but the per-emission OnNextAsyncCore is sync-completed - /// when the predicate rejects, avoiding any state-machine box on rejection. + /// Synchronous-predicate variant of , whose per-emission + /// path completes synchronously when the predicate rejects a value. /// /// The element type of the source sequence. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs index 054f9ce5..3fdaa43f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static methods that extend the functionality of asynchronous -/// observables, enabling advanced filtering, transformation, and composition operations. These methods are intended to -/// be used with types implementing asynchronous observable patterns, such as SignalAsync{T}. public static partial class SignalAsyncExtensions { /// Type-filtering operators for an observable source sequence. @@ -23,13 +20,10 @@ public static partial class SignalAsyncExtensions /// /// The reference type to filter and project elements to. Must be a class. /// An observable sequence containing only the elements of type TResult from the original sequence. - /// Elements that are not of type TResult are ignored and not included in the resulting - /// sequence. This method is useful for working with observable sequences containing heterogeneous types, - /// allowing subscribers to focus on elements of a specific type. [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public extension API — caller specifies TResult explicitly: source.OfType().")] + Justification = "The caller chooses TResult; no parameter carries it.")] public IObservableAsync OfType() where TResult : class { @@ -49,7 +43,7 @@ public IObservableAsync OfType() [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Deliberate lack of type inference.")] + Justification = "The caller chooses TResult; no parameter carries it.")] public IObservableAsync KeepType() where TResult : class { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/LastAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/LastAsync.cs index 12931f2e..c3435f6d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/LastAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/LastAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of extension methods for working with asynchronous observable sequences. -/// The methods in this class enable querying and manipulation of asynchronous observables, such as -/// retrieving the last element of a sequence. These extensions are designed to support asynchronous and reactive -/// programming patterns. public static partial class SignalAsyncExtensions { /// Last-element operators for an observable source sequence. @@ -22,6 +19,7 @@ public static partial class SignalAsyncExtensions /// predicate returns . /// A task that represents the asynchronous operation. The task result contains the last element that matches /// the predicate. + /// The sequence completes without a matching element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask LastAsync(Func predicate) => source.LastAsync(predicate, CancellationToken.None); @@ -32,6 +30,7 @@ public ValueTask LastAsync(Func predicate) => /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the last element that matches /// the predicate. + /// The sequence completes without a matching element. public async ValueTask LastAsync(Func predicate, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -44,9 +43,7 @@ public async ValueTask LastAsync(Func predicate, CancellationToken c /// Asynchronously returns the last element of the sequence. /// A task that represents the asynchronous operation. The task result contains the last element of the /// sequence. - /// If the sequence is empty, the behavior depends on the implementation and may result - /// in an exception being thrown. The operation is performed asynchronously and may not complete - /// immediately. + /// The sequence completes without producing an element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask LastAsync() => source.LastAsync(CancellationToken.None); @@ -55,9 +52,7 @@ public ValueTask LastAsync() => /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the last element of the /// sequence. - /// If the sequence is empty, the behavior depends on the implementation and may result - /// in an exception being thrown. The operation is performed asynchronously and may not complete - /// immediately. + /// The sequence completes without producing an element. public async ValueTask LastAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs index d8d3ceb3..5729144a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of extension methods for working with asynchronous observable sequences. -/// The methods in this class enable querying and retrieving elements from asynchronous observables, such -/// as obtaining the last element or a default value if no elements are found. These extensions are designed to support -/// asynchronous and cancellation-aware operations on observable sequences. public static partial class SignalAsyncExtensions { /// Last-or-default operators for an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs index 449aaaff..db561212 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class offers utility methods that enable manipulation and composition of -/// asynchronous observables, such as prepending values to a sequence. These methods facilitate common operations when -/// building reactive, asynchronous workflows. public static partial class SignalAsyncExtensions { /// Prepend (value-prepending) operators for an observable source sequence. @@ -39,9 +36,8 @@ public static partial class SignalAsyncExtensions /// The collection of values to emit before the original sequence. Cannot be null. /// An observable sequence that emits the specified values first, followed by the items from the current /// sequence. - /// The values in the provided collection are emitted in order before any items from the - /// original sequence. If the sequence is unsubscribed before completion, remaining values may not be - /// emitted. + /// Disposing the subscription while is being emitted drops the + /// remainder. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Prepend(IEnumerable values) => new LeadSignal(source, values); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/LongCountAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/LongCountAsync.cs index 4c588b66..41c4f9aa 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/LongCountAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/LongCountAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The methods in this class enable querying and aggregating data from asynchronous observables in a -/// manner similar to LINQ operators. These extensions are designed to be used with types that implement asynchronous -/// observable patterns, allowing for efficient and composable asynchronous data processing. public static partial class SignalAsyncExtensions { /// Asynchronous 64-bit element-counting operators for an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs index ecc68540..4eee32e5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for creating and transforming asynchronous observable sequences. -/// The methods in this class enable functional-style operations, such as projection, on asynchronous -/// observables. These extensions facilitate composing and manipulating streams of data in an asynchronous context, -/// similar to LINQ operations for synchronous observables. public static partial class SignalAsyncExtensions { /// Projection (Map/Select) operators for an observable source sequence. @@ -23,9 +20,8 @@ public static partial class SignalAsyncExtensions /// name="TDest"/> asynchronously. The function receives the source element and a cancellation token. /// An observable sequence of type containing the results of applying the selector /// function to each element of the source sequence. - /// The selector function is invoked for each element as it is observed. If the selector - /// function throws an exception or returns a faulted task, the error is propagated to the observer. The - /// operation supports cancellation via the provided cancellation token. + /// The selector runs for each element as it is observed; a thrown exception or a faulted task is + /// propagated to the observer. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Map( Func> selector) => @@ -36,9 +32,8 @@ public IObservableAsync Map( /// A function that transforms each element of the source sequence into a new value. Cannot be null. /// An observable sequence whose elements are the result of invoking the selector function on each element of /// the source sequence. - /// The selector function is applied to each element as it is observed. If the selector - /// throws an exception, the error is propagated to the observer. This method does not modify the source - /// sequence; it produces a new sequence with transformed elements. + /// The selector runs for each element as it is observed; a thrown exception is propagated to the + /// observer. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Map( Func selector) => @@ -78,11 +73,7 @@ public IObservableAsync Select( new MapSyncSignal(source, selector); } - /// - /// Async-selector variant of . - /// Allocates one observable wrapper and one sealed observer per subscription — no per-emission closure or - /// state-machine box from the previous Create<TDest>((observer, token) => ...) pattern. - /// + /// Applies an asynchronous selector to each source value, allocating one observer per subscription. /// The element type of the source sequence. /// The projected element type. /// The source observable. @@ -136,11 +127,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// - /// Synchronous-selector variant of . Same - /// allocation profile as but the per-emission OnNextAsyncCore - /// is sync-completed so no state-machine box is allocated when the downstream completes synchronously. - /// + /// Applies a synchronous selector to each source value, forwarding without an await state machine. /// The element type of the source sequence. /// The projected element type. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs index 7d848caf..cf132ebd 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs @@ -10,10 +10,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of extension methods for creating and managing connectable asynchronous observables using various /// Signal types. /// -/// The methods in this class enable advanced multicasting scenarios for asynchronous observables, -/// allowing multiple subscribers to share a single subscription to the underlying data source. These methods support -/// different Signal types and configuration options, including stateless and replay behaviors, to accommodate a wide -/// range of reactive programming patterns. public static partial class SignalAsyncExtensions { /// Multicasting and publishing operators for an observable source sequence. @@ -25,10 +21,9 @@ public static partial class SignalAsyncExtensions /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence using /// the specified Signal. /// - /// The signal used to multicast the elements of the source sequence to multiple observers. Cannot be null. + /// The signal that multicasts the source elements to multiple observers. Cannot be null. /// A connectable observable sequence that multicasts the source sequence through the specified signal. - /// The returned connectable observable will not begin emitting items until its Connect - /// method is called. This allows multiple observers to subscribe before the sequence starts. + /// The source is not subscribed until Connect is called, so observers can subscribe first. public ConnectableSignalAsync Multicast(ISignalAsync signal) => new(source, signal); @@ -38,10 +33,7 @@ public ConnectableSignalAsync Multicast(ISignalAsync signal) => /// /// A connectable observable sequence that multicasts notifications to all subscribed observers. The sequence /// does not begin emitting items until its Connect method is called. - /// Use this method to create a hot observable that allows multiple observers to share a - /// single subscription to the source. This is useful for scenarios where you want to avoid multiple - /// subscriptions to the source sequence or coordinate the timing of subscriptions. The returned connectable - /// observable is asynchronous and supports concurrent observers. + /// Notifications are delivered to observers serially. public ConnectableSignalAsync Publish() => new(source, new SerialSignalAsync()); @@ -49,13 +41,11 @@ public ConnectableSignalAsync Publish() => /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence, /// using a Signal created with the specified options. /// - /// The options used to configure the Signal that will multicast the source sequence. Cannot be null. + /// The options that configure the multicasting signal. Cannot be null. /// A connectable observable sequence that multicasts the source sequence using a Signal configured with the /// specified options. /// names an unsupported publishing option. - /// The returned connectable observable does not begin emitting items until its Connect - /// method is called. Use this method to control when the subscription to the source sequence starts and to - /// share the subscription among multiple observers. + /// The source is not subscribed until Connect is called. public ConnectableSignalAsync Publish(SignalCreationOptions options) => new(source, options switch { @@ -77,9 +67,8 @@ public ConnectableSignalAsync Publish(SignalCreationOptions options) => /// The initial value to be emitted to subscribers before any values are emitted by the source sequence. /// A connectable observable sequence that multicasts the source sequence and replays the latest value, starting /// with the specified initial value. - /// Subscribers will immediately receive the initial value upon subscription, followed by - /// subsequent values from the source sequence. The returned connectable observable does not begin emitting - /// values until its Connect method is called. + /// A subscriber receives the latest value — the initial value until the source publishes one — and the + /// source is not subscribed until Connect is called. public ConnectableSignalAsync Publish(T initialValue) => new(source, new SerialReplayLatestSignalAsync(new(initialValue))); @@ -88,13 +77,12 @@ public ConnectableSignalAsync Publish(T initialValue) => /// starts with the specified initial value. /// /// The initial value to be emitted to subscribers before any items are emitted by the source sequence. - /// The options used to configure the behavior of the underlying behavior Signal. + /// The options that configure the underlying behavior signal. /// A connectable observable sequence that multicasts the source sequence and emits the specified initial value /// to new subscribers. /// names an unsupported publishing option. - /// The returned connectable observable will not begin emitting items from the source - /// sequence until its Connect method is called. Subscribers will immediately receive the most recent value, - /// starting with the specified initial value, upon subscription. + /// A subscriber receives the latest value — the initial value until the source publishes one — and the + /// source is not subscribed until Connect is called. public ConnectableSignalAsync Publish(T initialValue, BehaviorSignalCreationOptions options) => new(source, options switch { @@ -118,9 +106,7 @@ public ConnectableSignalAsync Publish(T initialValue, BehaviorSignalCreationO /// /// A connectable observable sequence that multicasts notifications from the source without retaining state /// between subscribers. - /// Use this method when you want to share a single subscription to the source among - /// multiple observers, but do not require the observable to cache or replay any items for new subscribers. Each - /// connection to the returned observable is independent and does not affect subsequent connections. + /// Each connection is independent of the ones around it, and no value is cached for late subscribers. public ConnectableSignalAsync StatelessPublish() => new(source, new SerialStatelessSignalAsync()); @@ -131,20 +117,15 @@ public ConnectableSignalAsync StatelessPublish() => /// The initial value to be emitted to subscribers before any values are published by the source sequence. /// A connectable observable sequence that multicasts the source sequence and replays the latest value, starting /// with the specified initial value. - /// The returned observable does not maintain any state between connections. Each - /// connection starts with the provided initial value and only replays the most recent value published during - /// that connection. This is useful for scenarios where late subscribers should always receive the latest value, - /// even if they subscribe after the source has started emitting. + /// Each connection starts from the initial value and replays only the most recent value published + /// during that connection; nothing is retained between connections. public ConnectableSignalAsync StatelessPublish(T initialValue) => new(source, new SerialStatelessReplayLatestSignalAsync(new(initialValue))); /// Creates a connectable observable sequence that replays only the most recent item to new subscribers. /// A connectable observable sequence that publishes the latest item to current and future subscribers until a /// new item is emitted. - /// This method enables late subscribers to immediately receive the most recently - /// published value, followed by subsequent values. The returned sequence does not replay earlier items beyond - /// the latest one. Use this method when you want all subscribers to observe the most recent value, regardless - /// of when they subscribe. + /// A late subscriber receives the most recent value on subscription; earlier values are not replayed. public ConnectableSignalAsync ReplayLatestPublish() => new(source, new SerialReplayLatestSignalAsync(Optional.Empty)); @@ -152,14 +133,12 @@ public ConnectableSignalAsync ReplayLatestPublish() => /// Creates a connectable observable sequence that replays only the latest published value to new subscribers, /// using the specified replay Signal creation options. /// - /// The options used to configure the replay Signal, such as buffer size, scheduler, or other replay behavior - /// settings. + /// The options that configure the replay signal. /// A connectable observable sequence that replays the most recent value to each new subscriber after /// connection. /// names an unsupported publishing option. - /// Use this method when you want late subscribers to receive only the most recently - /// published value, rather than the entire sequence or a fixed buffer. The returned connectable observable does - /// not begin emitting items until its Connect method is called. + /// A late subscriber receives the most recent value only, and the source is not subscribed until + /// Connect is called. public ConnectableSignalAsync ReplayLatestPublish(ReplayLatestSignalCreationOptions options) => new(source, options switch { @@ -183,10 +162,8 @@ public ConnectableSignalAsync ReplayLatestPublish(ReplayLatestSignalCreationO /// /// A connectable observable sequence that replays the most recent item to new subscribers and multicasts /// notifications to all current subscribers. - /// This method is stateless; each call returns a new connectable observable. Subscribers - /// that connect after an item has been published will immediately receive the latest item. This is useful for - /// scenarios where late subscribers should catch up with the most recent value without receiving the full - /// history. + /// Each connection replays only the most recent value published during that connection; nothing is + /// retained between connections. public ConnectableSignalAsync StatelessReplayLatestPublish() => new(source, new SerialStatelessReplayLatestSignalAsync(Optional.Empty)); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/OnDispose.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/OnDispose.cs index 7f3e3440..67ed6a73 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/OnDispose.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/OnDispose.cs @@ -5,10 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for composing and managing asynchronous observable sequences. -/// The SignalAsync class offers utility methods for working with asynchronous observables, enabling -/// additional behaviors such as resource cleanup or side-effect handling when subscriptions are disposed. These methods -/// are intended to simplify the creation and management of custom observable pipelines in asynchronous programming -/// scenarios. public static partial class SignalAsyncExtensions { /// Disposal-callback operators that run an action when the observable source subscription is disposed. @@ -20,9 +16,8 @@ public static partial class SignalAsyncExtensions /// A function that returns a ValueTask representing the asynchronous operation to execute upon disposal of the /// observable sequence. Cannot be null. /// An SignalAsync{T} that invokes the specified asynchronous callback when disposed. - /// Use this method to perform custom asynchronous cleanup or resource release logic when - /// the observable sequence is disposed. The callback is invoked when the subscription is disposed, either - /// explicitly or when the observer completes or errors. + /// The callback runs when the subscription is disposed, whether explicitly or through completion or + /// error. public IObservableAsync OnDispose(Func disposeAction) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -34,9 +29,8 @@ public IObservableAsync OnDispose(Func disposeAction) /// Registers an action to be invoked when the observable sequence is disposed. /// The action to execute when the subscription is disposed. Cannot be null. /// An observable sequence that invokes the specified action upon disposal of the subscription. - /// Use this method to perform cleanup or resource release logic when a subscription to - /// the observable is disposed. The specified action is called synchronously during disposal. If multiple - /// actions are registered through chained calls, each will be invoked in the order registered. + /// The action runs synchronously during disposal; chained registrations run in the order they were + /// added. public IObservableAsync OnDispose(Action disposeAction) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -46,7 +40,7 @@ public IObservableAsync OnDispose(Action disposeAction) } } - /// Wraps a source observable with an async-action OnDispose observer without the prior Create<T> wrapper layer. + /// Wraps a source observable with an observer that awaits a callback when the subscription is disposed. /// The element type. /// The upstream observable. /// The async dispose action. @@ -62,7 +56,7 @@ ValueTask IObservableAsync.SubscribeAsync( } } - /// Wraps a source observable with a sync-action OnDispose observer without the prior Create<T> wrapper layer. + /// Wraps a source observable with an observer that runs an action when the subscription is disposed. /// The element type. /// The upstream observable. /// The sync dispose action. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/OnErrorResumeAsFailure.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/OnErrorResumeAsFailure.cs index a5a1d6fa..658c5ff1 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/OnErrorResumeAsFailure.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/OnErrorResumeAsFailure.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static methods that extend the functionality of asynchronous -/// observables, enabling advanced composition and error handling scenarios. These methods are intended to be used with -/// types that implement asynchronous push-based notification patterns. public static partial class SignalAsyncExtensions { /// Error-handling operators that convert source errors into failure completion results for an observable source sequence. @@ -23,9 +20,8 @@ public static partial class SignalAsyncExtensions /// /// An observable sequence that emits the same elements as the source, but represents errors as failure results /// instead of throwing exceptions. - /// This method enables error handling by transforming exceptions into failure notifications - /// within the sequence, rather than terminating the sequence with an error. Consumers can inspect the result to - /// determine whether an operation succeeded or failed. + /// The completion result carries the error, so an observer inspects that result to tell success from + /// failure. public IObservableAsync OnErrorResumeAsFailure() { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs index 934ba9bb..ff73bfff 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs @@ -8,8 +8,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides Zip extension methods for asynchronous observable sequences. -/// Zip combines elements from two observable sequences pair-wise. The nth element from -/// each source is paired together. The resulting sequence completes when either source completes. public static partial class SignalAsyncExtensions { /// Pair/Zip operators for a first observable source sequence. @@ -25,6 +23,8 @@ public static partial class SignalAsyncExtensions /// An observable sequence whose elements are the result of pair-wise combining the source /// elements using the result selector. /// Thrown if any argument is null. + /// The nth element of one source is paired with the nth element of the other, so an element waits until + /// its partner arrives. The result completes when either source completes with no pending pair. public IObservableAsync Pair( IObservableAsync second, Func resultSelector) @@ -37,20 +37,13 @@ public IObservableAsync Pair( } } - /// - /// Represents an observable sequence that combines the latest values from two asynchronous observable sequences - /// into a single result sequence using a specified selector function. - /// + /// Pairs elements of two sources in arrival order and projects each pair through a selector. /// The type of the elements in the first source sequence. /// The type of the elements in the second source sequence. /// The type of the elements in the resulting sequence produced by the selector function. /// The first asynchronous observable sequence to combine. /// The second asynchronous observable sequence to combine. /// A function that specifies how to combine elements from the first and second sequences into a result element. - /// The resulting sequence produces a value each time both source sequences have produced an - /// element, pairing elements in the order they are received. The sequence completes when either source sequence - /// completes and there are no more pairs to combine. If either source sequence signals an error, the resulting - /// sequence will propagate that error. internal sealed class ZipSignal( IObservableAsync first, IObservableAsync second, @@ -80,7 +73,7 @@ async ValueTask IObservableAsync.SubscribeAsync( /// Shared state that coordinates pair-wise combination of elements from both source sequences. /// The downstream observer to forward combined results to. - /// The function used to combine paired elements. + /// The function that combines paired elements. internal sealed class ZipState( IObserverAsync observer, Func resultSelector) : IAsyncDisposable @@ -251,11 +244,7 @@ internal async ValueTask OnCompleted2Async(Result result) internal ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) => observer.OnErrorResumeAsync(error, cancellationToken); - /// - /// Links the original subscribe-time cancellation token into this state's dispose chain so - /// later per-emission methods can rely on instead of allocating - /// a per-emission linked CTS. - /// + /// Routes cancellation of the subscribe-time token into , so per-emission code needs no linked source. /// The subscribe-time token. internal void LinkExternalCancellation(CancellationToken external) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs index 8445b1de..4f062322 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Async; /// Fused filter / projection observables that back the parity-helper extension methods in . public static partial class SignalAsyncExtensions { - /// - /// Fuses the previous Create<(T, T)> + closure-based Pairwise implementation - /// into a single layer; per-subscription state is held in fields - /// instead of a captured closure, eliminating the per-emission async-lambda state-machine box. - /// + /// Emits each adjacent pair of source values, holding the prior value in a field rather than a closure. /// The element type. /// The upstream observable. internal sealed class PairwiseSignal(IObservableAsync source) : IObservableAsync<(T Previous, T Current)> @@ -40,7 +36,7 @@ internal sealed class PairwiseWitness( IObserverAsync<(T Previous, T Current)> downstream, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// The previously-seen value; valid only when is set. + /// The last value seen; valid only when is set. private T? _previous; /// Latches to after the first upstream emission. @@ -71,12 +67,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// - /// Combined skip-then-cast observable that fuses the previous - /// SkipWhile(value is null).Select(value!) composition into a single - /// layer. Once a non-null value has been seen the gate latches - /// off and the operator becomes a transparent null-stripping forwarder. - /// + /// Skips values until the first non-null one arrives, then forwards every later value as non-nullable. /// The non-nullable element type seen downstream. /// The nullable source observable. internal sealed class SkipWhileNullSignal(IObservableAsync source) : IObservableAsync @@ -135,12 +126,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// - /// Combined filter-and-cast observable that fuses the previous Where(value is not null).Select(value!) - /// composition into a single layer. Halves the per-emission observer-chain - /// cost (one TryEnter / Exit, one set of chain-aware-cancellation wiring) for what is fundamentally a - /// null-stripping projection over a single source. - /// + /// Forwards the non-null source values as non-nullable through a single observer layer. /// The non-nullable element type seen downstream. /// The nullable source observable. internal sealed class WhereIsNotNullSignal(IObservableAsync source) : IObservableAsync @@ -188,13 +174,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// - /// Combined seed-and-distinct observable that fuses the previous - /// StartWith(seed).DistinctUntilChanged() composition into a single - /// layer. The seed is emitted on subscribe and tracked as the - /// initial "last value"; source emissions that compare equal under - /// are swallowed. - /// + /// Emits the seed on subscribe, then forwards only the source values that differ from the last emission. /// The element type. /// The source observable. /// The seed value emitted on subscribe. @@ -221,14 +201,14 @@ async ValueTask IObservableAsync.SubscribeAsync( /// Per-subscription observer that swallows values equal to the most-recently-forwarded one. /// The downstream observer. - /// The seed value already emitted during subscription; treated as the initial "last forwarded value". + /// The seed value emitted during subscription, which becomes the initial last-forwarded value. /// The subscribe-time cancellation token, linked into the dispose chain. internal sealed class LatestOrDefaultWitness( IObserverAsync downstream, T seed, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// Equality comparer used for the distinct check; matches DistinctUntilChanged's default. + /// The equality comparer for the distinct check. private static readonly EqualityComparer Comparer = EqualityComparer.Default; /// The most-recently-forwarded value; seeded by the constructor. @@ -256,13 +236,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// - /// Combined filter-and-take-one observable that fuses the previous - /// Where(predicate).Take(1) composition into a single - /// layer. The first emission matching the predicate is forwarded, completion is signalled - /// downstream, and the source subscription is disposed via the base observer's - /// dispose-cascade. - /// + /// Forwards the first value matching the predicate, then completes and tears down the source subscription. /// The element type. /// The source observable. /// The predicate matched against each value. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs index 900f304f..1c1dea53 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Async; /// Fused operator observables backing the parity-helper extension methods in . public static partial class SignalAsyncExtensions { - /// - /// Fuses Return(initial).Concat(source.Scan(initial, accumulator)) into a single layer. - /// The seed is emitted on subscribe and tracked as the initial accumulator; each upstream - /// emission updates the accumulator and forwards the new value. - /// + /// Emits the initial accumulator on subscribe, then folds each source value into it and forwards the result. /// The upstream element type. /// The accumulator type. /// The upstream observable. @@ -43,7 +39,7 @@ async ValueTask IObservableAsync.SubscribeAsync( /// Per-subscription accumulator observer. /// The downstream observer. - /// The seed accumulator value already emitted during subscription. + /// The seed accumulator value emitted during subscription. /// The synchronous accumulator. /// The subscribe-time cancellation token. internal sealed class ScanWithInitialWitness( @@ -104,7 +100,7 @@ async ValueTask IObservableAsync.SubscribeAsync( /// Per-subscription async accumulator observer. /// The downstream observer. - /// The seed accumulator value already emitted during subscription. + /// The seed accumulator value emitted during subscription. /// The asynchronous accumulator. /// The subscribe-time cancellation token. internal sealed class ScanWithInitialAsyncWitness( @@ -151,16 +147,12 @@ private async ValueTask AwaitAndForwardAsync( } } - /// - /// Fuses DistinctUntilChanged().Throttle(window).DistinctUntilChanged() into a single - /// observer that tracks upstream-distinct, debounce-timer supersession, and downstream-distinct - /// state. Supersession follows the same id-based pattern used by ThrottleSignal: a - /// superseded delay still runs but its result is discarded. - /// + /// Drops upstream duplicates, debounces each value, and suppresses duplicates again before forwarding. /// The element type. /// The upstream observable. /// The debounce window. - /// The time provider used for the debounce timer. + /// The time provider for the debounce timer. + /// A superseded delay is not cancelled: it runs to completion and its result is dropped by the id check. internal sealed class ThrottleDistinctSignal( IObservableAsync source, TimeSpan dueTime, @@ -212,18 +204,14 @@ internal sealed class ThrottleDistinctWitness( /// Set to after the first value has been forwarded downstream. private bool _hasEmitted; - /// Monotonically increasing identifier used to detect supersession. + /// Monotonically increasing identifier stamped on each pending delay; a mismatch marks it superseded. 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. + /// Claims the emission when the stamped id is current and the value differs from the last forwarded one. /// 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. + /// when the caller should forward the value; when it + /// was superseded or duplicates the last forwarded value. internal bool TryClaimEmission(T value, long id) { lock (_gate) @@ -297,11 +285,7 @@ protected override ValueTask DisposeAsyncCore() return base.DisposeAsyncCore(); } - /// 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. + /// Waits the debounce window, then forwards the value when approves it. /// The candidate value. /// The id stamped when this delay was started. /// The cancellation token. @@ -321,17 +305,14 @@ private async Task FireAfterDelayAsync(T value, long id, CancellationToken cance } catch (Exception e) { + // ReportUnhandledException drops cancellation itself, so no separate catch for it. UnhandledExceptionHandler.ReportUnhandledException(e); } } } } - /// - /// Fuses the DropIfBusy closure-based pipeline into a single observer layer. - /// Synchronously-completing async actions and downstream emissions take a zero-state-machine - /// fast path; only when the inner action genuinely suspends does the slow path run. - /// + /// Runs the action for one value at a time and drops the values that arrive while it is running. /// The element type. /// The upstream observable. /// The async side-effect invoked for accepted values. @@ -356,7 +337,7 @@ async ValueTask IObservableAsync.SubscribeAsync( return sink; } - /// Per-subscription witness that drops upstream emissions while a prior action is still pending. + /// Per-subscription witness that drops upstream emissions while a prior action is pending. /// The downstream observer. /// The async side-effect invoked for accepted values. /// The subscribe-time cancellation token, linked into the dispose chain. @@ -445,18 +426,14 @@ private async ValueTask AwaitFullAsync(ValueTask actionTask, T value, Cancellati } } - /// - /// Fuses Select(condition ? Return(value) : Return(value).Delay(...)).Switch() into a - /// single observer layer. Bypass-true values flow through with zero allocation; bypass-false - /// values schedule a fire-and-forget delay with id-based supersession (the same pattern - /// uses) so the previous pending delay is - /// effectively cancelled on every new upstream value. - /// + /// Forwards a value at once when the condition holds, otherwise after the debounce window elapses. /// The element type. /// The upstream observable. /// The debounce window applied to bypass-false values. /// When the value bypasses the delay and is forwarded immediately. - /// The time provider used for the debounce timer. + /// The time provider for the debounce timer. + /// Each new upstream value supersedes a pending delay, which is not cancelled: it runs to completion and its + /// result is dropped by the id check. internal sealed class DebounceUntilSignal( IObservableAsync source, TimeSpan debounce, @@ -496,16 +473,13 @@ internal sealed class DebounceUntilWitness( /// Synchronization gate protecting the id counter. private readonly Lock _gate = new(); - /// Monotonically increasing identifier used to detect supersession of pending delays. + /// Monotonically increasing identifier stamped on each pending delay; a newer value supersedes the old id. private long _id; - /// Post-delay supersession check. Extracted as an - /// method so tests can verify the supersession decision directly without racing the - /// delay timer. + /// Reports whether the stamped id is the current one, so no newer value has superseded this delay. /// 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. + /// when the caller should forward the value; when a + /// newer upstream value superseded it. internal bool IsCurrentEmission(long id) { lock (_gate) @@ -571,11 +545,7 @@ protected override ValueTask DisposeAsyncCore() return base.DisposeAsyncCore(); } - /// 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. + /// Waits the debounce window, then forwards the value when confirms it. /// The candidate value. /// The id stamped when this delay was started. /// The cancellation token. @@ -595,19 +565,14 @@ private async Task DelayAndEmitAsync(T value, long id, CancellationToken cancell } catch (Exception e) { + // ReportUnhandledException drops cancellation itself, so no separate catch for it. UnhandledExceptionHandler.ReportUnhandledException(e); } } } } - /// - /// Fuses source.SelectMany(values => values.ToAsyncSignal()) into a single - /// observer that iterates the inner enumerable inline and forwards each element. Avoids the - /// SelectMany+ToAsyncSignal per-emission machinery; arrays and - /// snapshots are walked with an indexed for loop to - /// dodge the enumerator-box allocation entirely. - /// + /// Forwards every element of each enumerable the source emits, walking arrays and lists by index. /// The flattened element type. /// The upstream observable of snapshots. internal sealed class ForEachEnumerableSignal(IObservableAsync> source) : IObservableAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs index 0e76ef92..e6db6101 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs @@ -8,20 +8,13 @@ namespace ReactiveUI.Primitives.Async; -/// -/// The shared upstream coordinator backing the Partition parity helper. It lives apart from the -/// other fused operators because it is the only one that fans a single subscription out to two -/// observables, so its state is a branch table rather than a per-subscription witness. -/// +/// Provides the shared upstream coordinator backing the Partition parity helper. public static partial class SignalAsyncExtensions { /// - /// Coordinates the shared upstream subscription and the two branch observables produced by - /// Partition. Maintains a single source subscription that is started when the first - /// branch subscribes and torn down when the last branch disposes. Each upstream emission - /// evaluates the predicate exactly once and dispatches to the branch observer (if any) - /// subscribed at that moment — no Publish/RefCount/intermediate-signal - /// allocations on the per-emission path. + /// Shares one upstream subscription between the two Partition branches, starting it when the first branch + /// subscribes and tearing it down when the last one disposes. Each emission dispatches to whichever branch observer + /// the predicate selects, or to nothing when that branch has no subscriber. /// /// The element type partitioned across the two branches. internal sealed class PartitionCoordinator @@ -112,14 +105,10 @@ internal async ValueTask SubscribeBranchAsync( return new BranchSubscription(this, isTrueBranch); } - /// 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. + /// Attaches an in-flight upstream subscription unless both branches have gone away first. /// 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. + /// when the subscription was attached and the caller should leave it running; + /// when both branches are gone and the caller should dispose it. internal bool TryAttachSourceSubscription(IAsyncDisposable subscription) { lock (_gate) @@ -134,11 +123,7 @@ internal bool TryAttachSourceSubscription(IAsyncDisposable subscription) } } - /// Attempts to attach the just-created upstream subscription and disposes it if - /// both branches have raced ahead and already disposed. The dispose branch is only - /// reachable under genuine concurrent disposal during in-flight subscribe, so the entire - /// helper is isolated and excluded from coverage; - /// itself is covered by direct unit tests. + /// Attaches the new upstream subscription, disposing it when both branches disposed during the subscribe. /// The freshly-created upstream subscription. /// A task that completes once the subscription has been attached or disposed. [ExcludeFromCodeCoverage] @@ -258,8 +243,7 @@ internal sealed class PartitionBranchSignal(bool isTrueBranch) : IObservableAsyn ValueTask IObservableAsync.SubscribeAsync( IObserverAsync observer, CancellationToken cancellationToken) => - // The PartitionBranchSignal is created by the coordinator's constructor; the - // coordinator field is filled in below. + // The coordinator's constructor sets Coordinator on both branches, so it is never null here. Coordinator.SubscribeBranchAsync(isTrueBranch, observer, cancellationToken); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Start.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Start.cs index 6875ede0..cbfca686 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Start.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Start.cs @@ -5,9 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides static methods for creating and manipulating asynchronous observable sequences. -/// The SignalAsync class offers factory methods and utilities for working with asynchronous -/// observables, enabling reactive programming patterns with support for asynchronous event streams. Members of this -/// class are thread-safe and designed for use in concurrent environments. public static partial class SignalAsync { /// Creates an observable sequence that executes the supplied function and emits its result. @@ -24,7 +21,7 @@ public static IObservableAsync Start(Func function) /// Creates an observable sequence that executes the supplied function and emits its result. /// The result type. /// The function to execute. - /// An optional scheduler used to start the function. + /// An optional scheduler that runs the function. /// An observable sequence that emits the function result and then completes. public static IObservableAsync Start(Func function, TaskScheduler? taskScheduler) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs index 876d7157..3ca19c7c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs @@ -7,16 +7,11 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides async-native counterparts for high-value helper operators exposed by the synchronous reactive surface in -/// this repository. -/// -/// These members intentionally compose existing async operators from this namespace so parity is achieved via -/// the library's own async primitives instead of by delegating to System.Reactive implementations. +/// Provides async-native counterparts for the helper operators on the synchronous reactive surface. [SuppressMessage( "StyleCop.CSharp.OrderingRules", "SA1201:ElementsShouldAppearInTheCorrectOrder", - Justification = "C# 14 extension methods")] + Justification = "Extension blocks group members by receiver rather than by element kind.")] public static partial class SignalAsyncExtensions { /// Aggregation parity helper operators for a sequence of boolean observable source sequences. @@ -175,7 +170,7 @@ public IObservableAsync DoOnSubscribe(Func acti return new DoOnSubscribeAsyncSignal(source, action); } - /// Drops source values while the previous asynchronous action is still running. + /// Drops source values that arrive while the previous asynchronous action runs. /// The asynchronous action to execute for accepted values. /// A sequence that emits only values that were accepted while the operator was idle. public IObservableAsync DropIfBusy(Func asyncAction) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Reattempt.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Reattempt.cs index 5c3b2e70..6de70feb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Reattempt.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Reattempt.cs @@ -5,8 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides Retry extension methods for asynchronous observable sequences. -/// Retry re-subscribes to the source sequence upon failure, enabling automatic recovery -/// from transient errors. An optional retry count limits the number of re-subscription attempts. public static partial class SignalAsyncExtensions { /// Retry operators for an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs index 2323c1ce..dcd5f9fe 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for composing and handling asynchronous observable sequences. -/// The methods in this class enable advanced error handling and composition scenarios for asynchronous -/// observables. These extensions are intended to be used with the SignalAsync{T} type to facilitate robust, -/// composable, and resilient asynchronous data streams. public static partial class SignalAsyncExtensions { /// Error-handling operators for an observable source sequence. @@ -26,10 +23,7 @@ public static partial class SignalAsyncExtensions /// An observable sequence that emits items from the source sequence, or from the handler-provided sequence if /// an exception is encountered. /// Thrown if the source sequence or is null. - /// Use this method to recover from errors in the source sequence by switching to an - /// alternative observable sequence. The handler function is called with the exception, allowing custom error - /// recovery logic. If the handler itself throws an exception, the resulting sequence completes with that - /// exception. + /// If the handler throws, the resulting sequence completes with that exception. public IObservableAsync Recover(Func> handler) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -56,9 +50,8 @@ public IObservableAsync Resume(IObservableAsync fallback) /// an error occurs. /// An observable sequence that resumes with the sequence returned by the handler when an error is encountered, /// and ignores the error after handling. - /// If an error occurs and the handler is invoked, the error is also reported to the - /// global unhandled exception handler before being ignored. This method allows the sequence to continue without - /// propagating the error to subscribers. + /// An error-resume notification is reported to the global unhandled-exception handler rather than + /// forwarded downstream, so subscribers never observe it. public IObservableAsync CatchAndIgnoreErrorResume(Func> handler) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -75,11 +68,7 @@ public IObservableAsync CatchAndIgnoreErrorResume(Func - /// Observable wrapper for . - /// Allocates one observable wrapper and one sealed observer per subscription — no per-emission closure or - /// state-machine box from the previous Create<T>((observer, token) => ...) pattern. - /// + /// Subscribes the handler-produced fallback observable when the source completes with a failure. /// The element type of the source sequence. /// The source observable. /// The fallback handler invoked with the source exception when the source completes with a failure. @@ -109,9 +98,7 @@ async ValueTask IObservableAsync.SubscribeAsync( return sink; } - /// Per-subscription witness that forwards OnNext verbatim, delegates error-resume to the - /// supplied callback (or the downstream when none was supplied), and on a failed completion subscribes the - /// handler-produced fallback observable in place of forwarding the failure. + /// Forwards values, routes error-resume to the callback or downstream, and swaps in the fallback on failure. /// The downstream witness. /// The fallback factory. /// Optional async error-resume callback. @@ -123,8 +110,7 @@ internal sealed class CatchWitness( Func? onErrorResume, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// Holds the handler-produced subscription so it disposes with the sink. Single-assignment - /// because the handler is subscribed at most once (on a failed source completion). + /// Holds the handler-produced subscription, assigned at most once, so it disposes with the sink. private readonly SingleAssignmentDisposableAsync _handlerDisposable = new(); /// The subscribe-time token, reused when subscribing the fallback handler observable. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs index d848bfe9..3e5722b8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides Aggregate (fold/reduce) extension methods for asynchronous observable sequences. -/// Aggregate applies an accumulator function over each element of the observable sequence -/// and returns the final accumulated value when the sequence completes. This is equivalent to a fold -/// or reduce operation. public static partial class SignalAsyncExtensions { /// Aggregate (fold/reduce) operators for an observable source sequence. @@ -41,7 +38,7 @@ public ValueTask AggregateAsync( /// The initial accumulator value. /// An asynchronous accumulator function to invoke on each element. Receives the /// current accumulated value, the current element, and a cancellation token. - /// A cancellation token that can be used to cancel the operation. + /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. /// Thrown if is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -75,7 +72,7 @@ public ValueTask AggregateAsync( /// The initial accumulator value. /// An accumulator function to invoke on each element. Receives the current /// accumulated value and the current element. - /// A cancellation token that can be used to cancel the operation. + /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. /// Thrown if is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -113,7 +110,7 @@ public ValueTask AggregateAsync( /// The initial accumulator value. /// An accumulator function to invoke on each element. /// A function to transform the final accumulated value into the result value. - /// A cancellation token that can be used to cancel the operation. + /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the transformed result. /// Thrown if or /// is null. @@ -149,7 +146,7 @@ public ValueTask ReduceAsync( /// The initial accumulator value. /// An asynchronous accumulator function to invoke on each element. Receives the /// current accumulated value, the current element, and a cancellation token. - /// A cancellation token that can be used to cancel the operation. + /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. /// Thrown if is null. public async ValueTask ReduceAsync( @@ -190,7 +187,7 @@ public ValueTask ReduceAsync( /// The initial accumulator value. /// An accumulator function to invoke on each element. Receives the current /// accumulated value and the current element. - /// A cancellation token that can be used to cancel the operation. + /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. /// Thrown if is null. public async ValueTask ReduceAsync( @@ -235,7 +232,7 @@ public ValueTask ReduceAsync( /// The initial accumulator value. /// An accumulator function to invoke on each element. /// A function to transform the final accumulated value into the result value. - /// A cancellation token that can be used to cancel the operation. + /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the transformed result. /// Thrown if or /// is null. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs index 791dbecd..59ee2333 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs @@ -10,9 +10,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The methods in this class enable advanced operations on asynchronous observables, such as reference -/// counting for connectable observables. These utilities are intended to be used with types that implement asynchronous -/// observer patterns. public static partial class SignalAsyncExtensions { /// Reference-counting operators for a connectable observable source sequence. @@ -25,9 +22,6 @@ public static partial class SignalAsyncExtensions /// subscribes, and disconnects when the last observer unsubscribes. /// /// An observable sequence that stays connected to the source as long as there is at least one subscription. - /// This operator is useful for sharing a single subscription to the underlying connectable - /// observable among multiple subscribers. When the last observer unsubscribes, the connection to the source is - /// automatically disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync RefCount() => new RefCountSignal(source); @@ -41,7 +35,7 @@ public IObservableAsync RefCount() => /// The connectable observable to manage with reference counting. internal sealed class RefCountSignal(ConnectableSignalAsync source) : IObservableAsync, IDisposable { - /// The asynchronous gate used to serialize subscribe and dispose operations. + /// The asynchronous gate that serializes subscribe and dispose operations. private readonly AsyncSerialGate _gate = new(); /// The current number of active subscribers. @@ -62,8 +56,7 @@ internal sealed class RefCountSignal(ConnectableSignalAsync source) : IObs [SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = - "IDisposable.Dispose is intrinsically synchronous; this method must tear down the async connection on the sync dispose path.")] + Justification = "IDisposable.Dispose is synchronous and must tear down the asynchronous connection.")] internal void Dispose(bool disposing) { if (Interlocked.Exchange(ref _disposedValue, 1) != 0) @@ -94,7 +87,7 @@ async ValueTask IObservableAsync.SubscribeAsync( { using (await _gate.EnterAsync(cancellationToken).ConfigureAwait(false)) { - // incr refCount before Subscribe(completed source decrement refCxount in Subscribe) + // Increment before subscribing: a source that completes during the subscribe decrements it again. ++_refCount; var needConnect = _refCount == 1; RefCountWitness refCountWitness = new(this, observer); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs index 727a9e2a..9a9bfa0b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs @@ -5,9 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides Delay extension methods for asynchronous observable sequences. -/// Delay time-shifts the observable sequence by the specified time span. Each element is -/// emitted after a relative delay from the time it was produced by the source. Errors and completion -/// are not delayed. public static partial class SignalAsyncExtensions { /// Delay operators that time-shift an observable source sequence. @@ -22,6 +19,8 @@ public static partial class SignalAsyncExtensions /// The time span by which to delay each element notification. Must be non-negative. /// An observable sequence with element notifications time-shifted by the specified duration. /// Thrown if is negative. + /// Only element notifications are delayed; errors and completion are forwarded as they arrive. A zero + /// interval returns the source unchanged. public IObservableAsync Shift(TimeSpan delayInterval) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(delayInterval, TimeSpan.Zero); @@ -36,7 +35,7 @@ public IObservableAsync Shift(TimeSpan delayInterval) /// The type of elements in the sequence. /// The source observable sequence. /// The time span by which to delay each element notification. - /// The time provider used to control timing. + /// The time provider that schedules the delay. internal sealed class DelaySignal(IObservableAsync source, TimeSpan delayInterval, TimeProvider timeProvider) : IObservableAsync { /// @@ -51,7 +50,7 @@ ValueTask IObservableAsync.SubscribeAsync( /// A witness that delays each element by waiting before forwarding to the downstream witness. /// The downstream observer to forward delayed notifications to. /// The time span by which to delay each element notification. - /// The time provider used to control timing. + /// The time provider that schedules the delay. /// The subscribe-time cancellation token. internal sealed class DelayWitness( IObserverAsync observer, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs index c02a9c2b..5420d95b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs @@ -40,10 +40,7 @@ public IObservableAsync Catch(Func> handler) /// An observable sequence that emits items from the source sequence, or from the handler-provided sequence if /// an exception is encountered. /// Thrown if the source sequence or is null. - /// Use this method to recover from errors in the source sequence by switching to an - /// alternative observable sequence. The handler function is called with the exception, allowing custom error - /// recovery logic. If the handler itself throws an exception, the resulting sequence completes with that - /// exception. + /// If the handler throws, the resulting sequence completes with that exception. public IObservableAsync Catch( Func> handler, Func? onErrorResume) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs index 72381784..b504de43 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs @@ -30,7 +30,7 @@ public IObservableAsync DistinctUntilChanged() /// Returns an observable sequence that emits elements from the source sequence only when the current element is /// not equal to the previous element, as determined by the specified equality comparer. /// - /// An equality comparer used to determine whether consecutive elements are considered equal. + /// The comparer that decides whether consecutive elements are equal. /// An observable sequence that contains only distinct consecutive elements from the source sequence, as /// determined by the specified equality comparer. /// Thrown if is . @@ -46,7 +46,7 @@ public IObservableAsync DistinctUntilChanged(IEqualityComparer equalityCom /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive /// duplicates as determined by a key selector function. /// - /// The type of the key used to determine whether consecutive elements are considered duplicates. + /// The type of the key that decides whether consecutive elements are duplicates. /// A function that extracts the comparison key from each element in the source sequence. /// An observable sequence that contains only the elements from the source sequence that are not consecutive /// duplicates according to the specified key. @@ -63,9 +63,9 @@ public IObservableAsync DistinctUntilChangedBy(Func keySelecto /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive /// duplicates as determined by a key selector and equality comparer. /// - /// The type of the key used to determine whether consecutive elements are considered duplicates. + /// The type of the key that decides whether consecutive elements are duplicates. /// A function that extracts the comparison key from each element in the source sequence. - /// An equality comparer used to compare keys for equality. + /// The comparer that compares keys for equality. /// An observable sequence that contains only the elements from the source sequence that are not consecutive /// duplicates according to the specified key and comparer. /// Thrown if or is null. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs index ec93a5b6..27309ffb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs @@ -13,12 +13,12 @@ public static partial class SignalAsyncExtensions extension(IObservableAsync source) { /// - /// Applies a dueTime policy to the observable sequence. If the next element is not received within + /// Applies a timeout policy to the observable sequence. If the next element is not received within /// the specified time span, the sequence completes with a . /// /// The maximum time span allowed between consecutive elements. Must be positive. /// An observable sequence that mirrors the source but completes with a - /// if any inter-element interval exceeds the specified dueTime. + /// if any inter-element interval exceeds the specified interval. /// Thrown if is negative or zero. public IObservableAsync Timeout(TimeSpan dueTime) { @@ -28,14 +28,14 @@ public IObservableAsync Timeout(TimeSpan dueTime) } /// - /// Applies a dueTime policy to the observable sequence. If the next element is not received within + /// Applies a timeout policy to the observable sequence. If the next element is not received within /// the specified time span, the sequence completes with a . /// /// The maximum time span allowed between consecutive elements. Must be positive. /// An optional time provider for controlling timing. If null, /// is used. /// An observable sequence that mirrors the source but completes with a - /// if any inter-element interval exceeds the specified dueTime. + /// if any inter-element interval exceeds the specified interval. /// Thrown if is negative or zero. public IObservableAsync Timeout(TimeSpan dueTime, TimeProvider? timeProvider) { @@ -45,13 +45,13 @@ public IObservableAsync Timeout(TimeSpan dueTime, TimeProvider? timeProvider) } /// - /// Applies a dueTime policy to the observable sequence. If the next element is not received within + /// Applies a timeout policy to the observable sequence. If the next element is not received within /// the specified time span, the sequence switches to the specified fallback observable. /// /// The maximum time span allowed between consecutive elements. Must be positive. - /// The fallback observable to switch to when a dueTime occurs. Cannot be null. + /// The fallback observable to switch to when a timeout occurs. Cannot be null. /// An observable sequence that mirrors the source, switching to the fallback sequence - /// if any inter-element interval exceeds the specified dueTime. + /// if any inter-element interval exceeds the specified interval. /// Thrown if is negative or zero. /// Thrown if is null. public IObservableAsync Timeout(TimeSpan dueTime, IObservableAsync fallback) @@ -63,15 +63,15 @@ public IObservableAsync Timeout(TimeSpan dueTime, IObservableAsync fallbac } /// - /// Applies a dueTime policy to the observable sequence. If the next element is not received within + /// Applies a timeout policy to the observable sequence. If the next element is not received within /// the specified time span, the sequence switches to the specified fallback observable. /// /// The maximum time span allowed between consecutive elements. Must be positive. - /// The fallback observable to switch to when a dueTime occurs. Cannot be null. + /// The fallback observable to switch to when a timeout occurs. Cannot be null. /// An optional time provider for controlling timing. If null, /// is used. /// An observable sequence that mirrors the source, switching to the fallback sequence - /// if any inter-element interval exceeds the specified dueTime. + /// if any inter-element interval exceeds the specified interval. /// Thrown if is negative or zero. /// Thrown if is null. public IObservableAsync Timeout( diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs index 93bb72bf..ff682fdb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs @@ -10,10 +10,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for asynchronous observable sequences, enabling operations such as retrieving a single /// element that matches a specified condition. /// -/// The methods in this class support querying and consuming asynchronous observables in a manner similar -/// to LINQ, but adapted for asynchronous and reactive scenarios. These extensions are intended for use with types -/// implementing the SignalAsync pattern, allowing developers to perform operations such as filtering and retrieving -/// elements in an asynchronous context. public static partial class SignalAsyncExtensions { /// Single-element operators for an observable source sequence. @@ -29,9 +25,7 @@ public static partial class SignalAsyncExtensions /// returns . /// A task that represents the asynchronous operation. The task result contains the single element that matches /// the predicate. - /// If no element satisfies the condition, or if more than one element satisfies the - /// condition, an exception is thrown. Use this method when exactly one element is expected to match the - /// predicate. + /// Both no match and more than one match throw. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync(Func predicate) => source.SingleAsync(predicate, CancellationToken.None); @@ -42,12 +36,10 @@ public ValueTask SingleAsync(Func predicate) => /// /// A function to test each element for a condition. The method returns the element for which this predicate /// returns . - /// A cancellation token that can be used to cancel the asynchronous operation. + /// The token that cancels the operation. /// A task that represents the asynchronous operation. The task result contains the single element that matches /// the predicate. - /// If no element satisfies the condition, or if more than one element satisfies the - /// condition, an exception is thrown. Use this method when exactly one element is expected to match the - /// predicate. + /// Both no match and more than one match throw. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync(Func predicate, CancellationToken cancellationToken) => SingleCoreAsync(source, predicate, cancellationToken); @@ -58,8 +50,7 @@ public ValueTask SingleAsync(Func predicate, CancellationToken cance /// /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence. - /// Use this method when you expect the sequence to contain exactly one element. If the - /// sequence is empty or contains more than one element, an exception is thrown. + /// Both an empty sequence and a sequence of more than one element throw. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync() => source.SingleAsync(CancellationToken.None); @@ -68,11 +59,10 @@ public ValueTask SingleAsync() => /// Asynchronously returns the single element of the sequence, and throws an exception if the sequence does not /// contain exactly one element. /// - /// A cancellation token that can be used to cancel the asynchronous operation. + /// The token that cancels the operation. /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence. - /// Use this method when you expect the sequence to contain exactly one element. If the - /// sequence is empty or contains more than one element, an exception is thrown. + /// Both an empty sequence and a sequence of more than one element throw. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync(CancellationToken cancellationToken) => SingleCoreAsync(source, null, cancellationToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs index 6754a5ee..d4366b6f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static extension methods that operate on instances of -/// SignalAsync{T}. These methods enable querying and manipulation of asynchronous observable sequences in a manner -/// similar to LINQ, supporting scenarios such as retrieving single elements or default values asynchronously. public static partial class SignalAsyncExtensions { /// Single-or-default operators for an observable source sequence. @@ -28,9 +25,6 @@ public static partial class SignalAsyncExtensions /// A value task that represents the asynchronous operation. The result contains the single element that matches /// the predicate, the specified default value if no such element is found, or throws an exception if more than /// one matching element exists. - /// If more than one element satisfies the condition, an exception is thrown. If no - /// elements satisfy the condition, the specified default value is returned. The operation observes the provided - /// cancellation token. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync( Func predicate, @@ -45,13 +39,10 @@ public static partial class SignalAsyncExtensions /// returns . /// The value to return if no element in the sequence satisfies the condition specified by . - /// A cancellation token that can be used to cancel the asynchronous operation. + /// The token that cancels the operation. /// A value task that represents the asynchronous operation. The result contains the single element that matches /// the predicate, the specified default value if no such element is found, or throws an exception if more than /// one matching element exists. - /// If more than one element satisfies the condition, an exception is thrown. If no - /// elements satisfy the condition, the specified default value is returned. The operation observes the provided - /// cancellation token. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync( Func predicate, @@ -73,7 +64,7 @@ public static partial class SignalAsyncExtensions /// Asynchronously returns the only element of a sequence, or a default value if the sequence is empty; this /// operation throws an exception if more than one element is found. /// - /// A cancellation token that can be used to cancel the asynchronous operation. + /// The token that cancels the operation. /// A value task that represents the asynchronous operation. The task result contains the single element of the /// sequence, or the default value of if the sequence is empty. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -88,9 +79,6 @@ public static partial class SignalAsyncExtensions /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence, the specified default value if the sequence is empty, or throws if more than one element is /// present. - /// Use this method when you expect the sequence to contain zero or one element. If the - /// sequence contains more than one element, an exception is thrown. If the sequence is empty, the specified - /// default value is returned. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync(T? defaultValue) => source.SingleOrDefaultAsync(defaultValue, CancellationToken.None); @@ -100,13 +88,10 @@ public static partial class SignalAsyncExtensions /// empty. Throws an exception if the sequence contains more than one element. /// /// The value to return if the sequence contains no elements. - /// A cancellation token that can be used to cancel the asynchronous operation. + /// The token that cancels the operation. /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence, the specified default value if the sequence is empty, or throws if more than one element is /// present. - /// Use this method when you expect the sequence to contain zero or one element. If the - /// sequence contains more than one element, an exception is thrown. If the sequence is empty, the specified - /// default value is returned. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync(T? defaultValue, CancellationToken cancellationToken) => SingleOrDefaultCoreAsync(source, null, defaultValue, cancellationToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs index d30c9ecb..f800c91d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs @@ -5,9 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static extension methods that enable LINQ-style and other -/// operations on asynchronous observables. These methods are intended to facilitate the composition and manipulation of -/// asynchronous data streams in a reactive programming style. public static partial class SignalAsyncExtensions { /// Element-skipping operators for an observable source sequence. @@ -29,10 +26,10 @@ public IObservableAsync Skip(int count) } } - /// Single-observer-layer Skip(count). Drops the first count emissions then forwards everything subsequent. + /// Drops the first count emissions, then forwards every later value. /// The element type. /// The upstream observable. - /// The number of leading emissions to drop (must be > 0; the zero case bypasses this observable entirely). + /// The number of leading emissions to drop, always greater than zero. internal sealed class SkipSignal(IObservableAsync source, int count) : IObservableAsync { /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs index dbe18a4d..506134ce 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs @@ -5,8 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides SkipWhile extension methods for asynchronous observable sequences. -/// SkipWhile bypasses elements in the source sequence as long as a predicate is satisfied, -/// then emits all remaining elements. public static partial class SignalAsyncExtensions { /// SkipWhile operators for an observable source sequence. @@ -48,11 +46,7 @@ public IObservableAsync SkipWhile(Func predicate) } } - /// - /// Synchronous-predicate SkipWhile as a single-observer-layer observable; once the - /// predicate returns the gate latches and every subsequent emission - /// forwards without a predicate call. - /// + /// Skips values while the predicate holds; after it first fails, every later value forwards untested. /// The element type. /// The upstream observable. /// The skip-while predicate. @@ -113,10 +107,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// - /// Async-predicate SkipWhile as a single-observer-layer observable with a sync-completion - /// fast path for the post-latch case. - /// + /// Skips values while the asynchronous predicate holds, then forwards every later value without awaiting it. /// The element type. /// The upstream observable. /// The async skip-while predicate. @@ -141,7 +132,7 @@ async ValueTask IObservableAsync.SubscribeAsync( return sink; } - /// Per-subscription observer with latched gate; once gated, async predicate is no longer invoked. + /// Per-subscription observer with a latched gate; once the gate opens, the predicate is not invoked again. /// The downstream observer. /// The async skip-while predicate. /// The subscribe-time cancellation token. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs index 7177e965..a0ff1658 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs @@ -7,8 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides the StartWith extension method for asynchronous observable sequences. -/// StartWith mirrors the System.Reactive naming convention and prepends one or more values -/// to the beginning of an observable sequence before its own emissions. public static partial class SignalAsyncExtensions { /// StartWith (value-prepending) operators for an observable source sequence. @@ -20,8 +18,7 @@ public static partial class SignalAsyncExtensions /// The value to prepend to the sequence. /// An observable sequence that emits the specified value first, followed by the elements /// of the source sequence. - /// This is equivalent to Prepend(T) and follows the System.Reactive - /// naming convention. + /// Equivalent to Prepend(T), under the System.Reactive name. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync StartWith(T value) => new LeadSignal(source, [value]); @@ -29,8 +26,8 @@ public static partial class SignalAsyncExtensions /// The values to prepend to the sequence. Cannot be null. /// An observable sequence that emits the specified values first, followed by the elements /// of the source sequence. - /// This is equivalent to Prepend(IEnumerable{T}) and follows the System.Reactive - /// naming convention. Values are emitted in the order they appear in the collection. + /// Equivalent to Prepend(IEnumerable<T>), under the System.Reactive name; the values are + /// emitted in collection order. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync StartWith(IEnumerable values) => new LeadSignal(source, values); @@ -38,8 +35,7 @@ public static partial class SignalAsyncExtensions /// The values to prepend to the sequence. /// An observable sequence that emits the specified values first, followed by the elements /// of the source sequence. - /// This overload accepts a params array for convenience. Values are emitted in the - /// order they appear in the array. + /// The values are emitted in array order. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync StartWith(params T[] values) => new LeadSignal(source, values); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs index 8b406e2d..4596fa62 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs @@ -7,12 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for subscribing to asynchronous observable sequences using various delegate-based overloads. -/// The methods in this class enable consumers to subscribe to an asynchronous observable sequence by -/// specifying delegate handlers for item notifications, error handling, and completion. These overloads offer both -/// synchronous and asynchronous delegate options, allowing for flexible integration with different programming models. -/// All subscriptions return an that should be disposed to terminate the subscription and -/// release resources. These methods are intended to simplify the process of observing asynchronous streams without -/// requiring explicit implementation of observer interfaces. public static partial class SignalAsyncExtensions { /// Delegate-based subscription operators for an observable source sequence. @@ -30,13 +24,11 @@ public static partial class SignalAsyncExtensions /// receives the exception and a cancellation token. If null, errors are not handled by the subscriber. /// An optional delegate that is invoked asynchronously when the data source completes successfully. The /// delegate receives a result indicating the completion status. If null, no action is taken on completion. - /// A cancellation token that can be used to cancel the subscription and any in-progress callbacks. + /// The token that cancels the subscription and any in-progress callbacks. /// A value task that represents the asynchronous operation. The result is an /// that can be disposed to unsubscribe from the data source. /// Thrown if the underlying data source is null. - /// The returned should be disposed when the subscription - /// is no longer needed to release resources and stop receiving notifications. Callbacks may be invoked - /// concurrently; implement thread safety in the provided delegates if required. + /// Callbacks may be invoked concurrently, so the supplied delegates must be thread-safe. public ValueTask SubscribeAsync( Func onNextAsync, Func? onErrorResumeAsync, @@ -81,7 +73,7 @@ public ValueTask SubscribeAsync( /// Subscribes to the observable sequence and invokes the specified action for each element received. /// An action to invoke for each element in the sequence. Cannot be null. - /// A cancellation token that can be used to cancel the subscription operation. + /// The token that cancels the subscription. /// A value task that represents the asynchronous subscription operation. The result contains an that can be disposed to unsubscribe from the sequence. /// Thrown if is null. @@ -118,13 +110,10 @@ public ValueTask SubscribeAsync(Action onNext) => /// subscriber. /// An optional action to invoke when the sequence completes. If null, completion is not handled by the /// subscriber. - /// A cancellation token that can be used to cancel the subscription. + /// The token that cancels the subscription. /// A value task that represents the asynchronous subscription operation. The result is an that can be disposed to unsubscribe from the sequence. /// Thrown if is null, or if the underlying source is null. - /// The returned should be disposed when the subscription - /// is no longer needed to release resources and stop receiving notifications. This method enables asynchronous, - /// push-based event handling for observable sequences. public ValueTask SubscribeAsync( Action onNext, Action? onErrorResume, @@ -180,7 +169,7 @@ public ValueTask SubscribeAsync(Func /// A function to invoke asynchronously for each item in the sequence. The function receives the item and a /// cancellation token, and returns a ValueTask that completes when processing is finished. - /// A token that can be used to cancel the subscription operation. + /// The token that cancels the subscription. /// A ValueTask that represents the asynchronous subscription operation. The result is an IAsyncDisposable that /// can be disposed to unsubscribe from the sequence. /// Thrown if the underlying source is null. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs index a6cffb2b..d20c1afa 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs @@ -20,9 +20,6 @@ public static partial class SignalAsyncExtensions /// /// An observable sequence that emits items from the most recently emitted inner observable sequence. When a new /// inner sequence is emitted, the previous one is unsubscribed. - /// This operator is commonly used to switch to a new data stream whenever a new inner - /// observable is produced, unsubscribing from the previous inner observable. Only items from the latest inner - /// observable are emitted to subscribers. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync SwitchTo() => new SwitchToSignal(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs index df6f2b63..1e6ea198 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs @@ -42,7 +42,7 @@ internal sealed class SwitchToCoordinator : IAsyncDisposable /// Disposable that holds the single outer subscription. private readonly SingleAssignmentDisposableAsync _outerDisposable = new(); - /// Cancellation token source used to signal disposal of the subscription. + /// The cancellation token source that signals disposal of the subscription. private readonly CancellationTokenSource _disposeCts = new(); /// Cached cancellation token from the dispose cancellation token source. @@ -183,11 +183,7 @@ internal async ValueTask AcceptInnerErrorAsync(Exception error, CancellationToke } } - /// - /// Links the original subscribe-time cancellation token into this subscription's dispose chain so - /// later per-emission methods can rely on instead of - /// allocating a per-emission linked CTS. - /// + /// Routes cancellation of the subscribe-time token into , so per-emission code needs no linked source. /// The subscribe-time token. internal void LinkExternalCancellation(CancellationToken external) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs index 85ab0e2b..e9571374 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs @@ -44,8 +44,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -90,8 +89,7 @@ public IObservableAsync SyncLatest CombineLatest( IObservableAsync src2, IObservableAsync src3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs index cda53b4e..082d77a9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs @@ -46,8 +46,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -95,8 +94,7 @@ public IObservableAsync SyncLatest CombineLatest( IObservableAsync src2, IObservableAsync src3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs index f5ed6e76..43bfb58a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs @@ -48,8 +48,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -100,8 +99,7 @@ public IObservableAsync SyncLatest CombineLatest( IObservableAsync src2, IObservableAsync src3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs index 81089c86..43be92df 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs @@ -50,8 +50,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -105,8 +104,7 @@ public IObservableAsync SyncLatest CombineLatest( IObservableAsync src2, IObservableAsync src3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs index be67a278..eca356c5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs @@ -52,8 +52,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -110,8 +109,7 @@ public IObservableAsync SyncLatest CombineLatest( IObservableAsync src2, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs index 7d9a6a76..2a52c9ff 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs @@ -54,8 +54,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest< T2, T3, @@ -130,8 +129,7 @@ public IObservableAsync SyncLatest< [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync CombineLatest< T2, T3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs index 9b2417c6..37bef000 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs @@ -56,8 +56,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -136,8 +135,7 @@ public IObservableAsync SyncLatest CombineLatest( IObservableAsync src2, IObservableAsync src3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs index 9182cb41..aac19c3b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs @@ -40,8 +40,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -80,8 +79,7 @@ public IObservableAsync SyncLatest [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync CombineLatest( IObservableAsync src2, IObservableAsync src3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs index 43134838..c2c38f5c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs @@ -42,8 +42,7 @@ public static partial class SignalAsyncExtensions [SuppressMessage( "Maintainability", "SST1472:Signatures should not declare too many parameters", - Justification = - "An arity-N combinator takes N distinctly-typed sources; a parameter object would need the same N type arguments.")] + Justification = "An arity-N combinator takes N distinctly-typed sources.")] public IObservableAsync SyncLatest( IObservableAsync src2, IObservableAsync src3, @@ -85,8 +84,7 @@ public IObservableAsync SyncLatest CombineLatest( IObservableAsync src2, IObservableAsync src3, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs index cecca337..21df8a1d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs @@ -16,20 +16,16 @@ public static partial class SignalAsyncExtensions /// An observable sequence that emits a snapshot of the latest values whenever any source produces a new value, /// after all sources have produced at least one value. /// Thrown if is . - /// - /// For perf reasons each emitted is a reference to a single shared buffer - /// owned by the subscription, not a fresh allocation. Downstream observers MUST consume the snapshot synchronously - /// inside their OnNextAsync handler; retaining a reference past the handler will surface the next - /// emission's values instead, because the buffer is overwritten under the operator's gate before each emit. - /// If you need a stable copy, project to one via the projecting CombineLatest overload or - /// .Select(static s => s.ToArray()). - /// + /// Each emitted is a reference to one buffer owned by the subscription, + /// not a fresh allocation. An observer must consume the snapshot inside its OnNextAsync handler: the buffer + /// is overwritten under the operator's gate before each emit, so a retained reference surfaces the next emission's + /// values. For a stable copy, use the projecting CombineLatest overload or + /// .Select(static s => s.ToArray()). public IObservableAsync> SyncLatest() { ArgumentExceptionHelper.ThrowIfNull(sources); - // Use the projecting sink with an identity selector so one subscription implementation - // backs both shapes. The static lambda avoids capturing enclosing state. + // An identity selector lets one subscription implementation back both shapes. return new SyncLatestEnumerableSignal>(sources, static s => s); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Take.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Take.cs index 42ed6eee..47c18d5f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Take.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Take.cs @@ -7,10 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static extension methods that enable advanced operations on -/// asynchronous observables, such as filtering, transformation, and sequence control. These methods are intended to be -/// used with the SignalAsync{T} type to facilitate reactive programming patterns in asynchronous -/// scenarios. public static partial class SignalAsyncExtensions { /// Element-limiting operators for an observable source sequence. @@ -24,9 +20,7 @@ public static partial class SignalAsyncExtensions /// sequence. If is zero, the resulting sequence completes immediately without emitting /// any elements. /// Thrown if is less than zero. - /// If the source sequence contains fewer elements than , all - /// available elements are emitted and the sequence completes. This method does not modify the source sequence; - /// it returns a new sequence with the specified behavior. + /// A source with fewer elements than emits all of them and completes. public IObservableAsync Take(int count) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -50,10 +44,10 @@ async ValueTask IObservableAsync.SubscribeAsync( } } - /// Single-observer-layer Take(count). Forwards values until the budget is exhausted, then signals completion downstream. + /// Forwards values until the count is exhausted, then signals completion downstream. /// The element type. /// The upstream observable. - /// The maximum number of values to forward (must be > 0; the zero case uses ). + /// The maximum number of values to forward, always greater than zero. internal sealed class TakeSignal(IObservableAsync source, int count) : IObservableAsync { /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.PredicateSignals.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.PredicateSignals.cs index 966a1ce3..c0e54a1f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.PredicateSignals.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.PredicateSignals.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Predicate-driven stop signals backing the TakeUntil(predicate) overloads declared in -/// TakeUntil.cs. Unlike the other take-until signals these have no second sequence, task or -/// token to race against: the stop condition is evaluated inline on each source element. -/// +/// Provides the predicate-driven stop signals that evaluate their stop condition inline on each source element. public static partial class SignalAsyncExtensions { /// Async observable that emits items from the source until the specified predicate returns true. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs index a32ba603..b7437840 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs @@ -11,10 +11,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides a set of extension methods for creating observable sequences that emit items from a source sequence until a /// specified condition is met or an external signal is received. /// -/// The methods in this class allow you to control the lifetime of an observable sequence based on -/// various triggers, such as another observable, a task, a cancellation token, or a predicate. These methods are useful -/// for scenarios where you need to automatically stop processing items from a source sequence when a certain event -/// occurs or a condition is satisfied. public static partial class SignalAsyncExtensions { /// Take-until operators that emit items from an observable source until a stop condition is met. @@ -173,8 +169,7 @@ public IObservableAsync TakeUntil( /// A cancellation token that, when canceled, will terminate the resulting observable sequence. /// An observable sequence that completes when the provided cancellation token is canceled or when the source /// sequence completes. - /// If the cancellation token is already canceled when the method is called, the - /// resulting observable sequence will complete immediately. + /// A token that is canceled at the time of the call completes the sequence immediately. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync TakeUntil(CancellationToken cancellationToken) => new CancellationStopSignal(source, cancellationToken); @@ -185,8 +180,6 @@ public IObservableAsync TakeUntil(CancellationToken cancellationToken) => /// An observable sequence that contains the elements from the source sequence up to, but not including, the /// first element for which the predicate returns true. /// Thrown if is null. - /// The element that causes the predicate to return true is not included in the resulting - /// sequence. Subsequent elements from the source are not emitted. public IObservableAsync TakeUntil(Func predicate) { ArgumentExceptionHelper.ThrowIfNull(predicate); @@ -337,11 +330,7 @@ ValueTask IObservableAsync.SubscribeAsync( () => subscription.SubscribeSourcesAsync(cancellationToken)); } - /// - /// Manages the subscription lifetime and completes when the cancellation token is canceled. - /// Composes for the shared gate / dispose-CTS / external- - /// link / gated-forwarding plumbing so this class only carries the operator-specific state. - /// + /// Manages the subscription lifetime and completes the sequence when the cancellation token is canceled. internal sealed class CancellationStopCoordinator : IAsyncDisposable { /// The parent observable that owns this subscription. @@ -503,14 +492,8 @@ internal void LinkExternalCancellation(CancellationToken external) => _lifecycle.LinkExternalCancellation(external); /// Hands the stop delegate the callback that ends the sequence. - /// - /// The completion runs on whichever thread calls the callback, not on a waiter parked behind it. - /// The caller of a synchronous notify(...) has every right to expect the sequence to have - /// ended by the time it returns, and where the downstream observer completes synchronously it now - /// does. The previous shape signalled a TaskCompletionSource whose continuations were - /// forced asynchronous, so the completion was merely queued and the caller could observe the - /// sequence still running. - /// + /// Completion runs on the thread that invokes the callback, so a synchronous notify ends the + /// sequence before it returns whenever the downstream observer completes synchronously. internal void AwaitStopThenComplete() { Volatile.Write(ref _stopRegistration, _parent._stopSignal(Stop)); @@ -611,7 +594,7 @@ ValueTask IObservableAsync.SubscribeAsync( () => subscription.SubscribeSourcesAsync(cancellationToken)); } - /// Manages the subscription lifetime and completes when the task finishes. Composes for the shared plumbing. + /// Manages the subscription lifetime and completes the sequence when the task finishes. internal sealed class TaskStopCoordinator : IAsyncDisposable { /// The parent observable that owns this subscription. @@ -718,10 +701,7 @@ ValueTask IObservableAsync.SubscribeAsync( async () => await subscription.SubscribeSourcesAsync(cancellationToken).ConfigureAwait(false)); } - /// - /// Manages subscriptions to both the source and signal observables, completing when the signal - /// fires. Composes for the shared plumbing. - /// + /// Manages subscriptions to both the source and the signal observable, completing when the signal fires. internal sealed class AsyncStopCoordinator : IAsyncDisposable { /// The parent observable that owns this subscription. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntilOptions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntilOptions.cs index 5de8ab08..83a8a4bd 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntilOptions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntilOptions.cs @@ -5,16 +5,12 @@ namespace ReactiveUI.Primitives.Async; /// Provides configuration options for controlling the behavior of the TakeUntil operator. -/// This type allows customization of how the source sequence responds to failures in the 'other' -/// sequence when using TakeUntil. It is immutable and thread-safe. [System.Diagnostics.DebuggerDisplay("TakeUntilOptions: SourceFailsWhenOtherFails = {SourceFailsWhenOtherFails}")] public sealed record TakeUntilOptions { /// Gets the default configuration options for the TakeUntil operation. - /// Use this property to obtain a standard set of options when no customization is required. The - /// returned instance is immutable and can be shared safely across multiple operations. public static TakeUntilOptions Default { get; } = new(); - /// Gets a value indicating whether the source operation fails when another related operation fails. + /// Gets a value indicating whether a failure of the other sequence also fails the result sequence. public bool SourceFailsWhenOtherFails { get; init; } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs index 514f24ea..69d099ba 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs @@ -5,8 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides TakeWhile extension methods for asynchronous observable sequences. -/// TakeWhile emits elements from the source sequence as long as a predicate is satisfied, -/// then completes the sequence when the predicate returns false. public static partial class SignalAsyncExtensions { /// TakeWhile operators for an observable source sequence. @@ -42,10 +40,7 @@ public IObservableAsync TakeWhile(Func predicate) } } - /// - /// Synchronous-predicate TakeWhile as a single-observer-layer observable. Forwards while - /// the predicate holds; on the first miss signals downstream completion and stops. - /// + /// Forwards values while the predicate holds; the first miss completes the sequence and stops. /// The element type. /// The upstream observable. /// The take-while predicate. @@ -108,7 +103,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// Async-predicate TakeWhile as a single-observer-layer observable with a sync-completion fast path. + /// Forwards values while the asynchronous predicate holds; the first miss completes the sequence. /// The element type. /// The upstream observable. /// The async take-while predicate. @@ -133,7 +128,7 @@ async ValueTask IObservableAsync.SubscribeAsync( return sink; } - /// Per-subscription observer with an async predicate sync-completion fast path. + /// Per-subscription observer that skips the await when the predicate completes synchronously. /// The downstream observer. /// The async take-while predicate. /// The subscribe-time cancellation token. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs index 444de697..52af3b00 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The methods in this class enable the addition of side effects, such as logging or resource -/// management, to asynchronous observable sequences without modifying their elements or control flow. These methods are -/// intended to be used as part of a fluent query or processing pipeline for asynchronous observables. public static partial class SignalAsyncExtensions { /// Side-effect (Tap/Do) operators that invoke callbacks for each notification of an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs b/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs index 48617333..5ea985e0 100644 --- a/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs +++ b/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs @@ -10,35 +10,30 @@ namespace ReactiveUI.Primitives.Async; /// Provides a mechanism for registering a global handler to process unhandled exceptions that occur during /// application execution. /// -/// Use this class to specify a custom action to be invoked when an unhandled exception is -/// encountered. By default, unhandled exceptions are written to the console. Registering a handler allows -/// applications to perform custom logging, cleanup, or user notification in response to unexpected errors. This -/// class does not handle exceptions of type OperationCanceledException. +/// The process-wide default writes the exception to . Register a +/// handler to log, clean up or notify instead. Cancellation is treated as normal flow and never reaches the +/// handler. public static class UnhandledExceptionHandler { /// The currently registered handler action invoked when an unhandled exception occurs. private static Action _unhandledException = TraceUnhandledException; - /// Gets the currently registered handler. Used for save/restore in tests. + /// Gets the handler that routes to. internal static Action CurrentHandler => _unhandledException; /// Registers a handler to be invoked when an unhandled exception occurs. - /// An action to execute when an unhandled exception is encountered. The exception instance is passed as a - /// parameter to the handler. Cannot be null. - /// Registering a new handler replaces any previously registered handler. The handler - /// will be called for each unhandled exception that occurs after registration. + /// The action that receives each unhandled exception. Cannot be null. + /// There is one handler per process and registration replaces it, so a library that registers here takes + /// the sink away from the host application. A handler that throws has its exception swallowed. public static void Register(Action unhandledExceptionHandler) => _unhandledException = unhandledExceptionHandler; /// Invokes the application's unhandled exception handler for exceptions that are not operation cancellations. /// The exception to be processed by the unhandled exception handler. Cannot be null. - /// OperationCanceledException instances are ignored and not passed to the - /// handler. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST1429:Handle, rethrow, or narrow this catch; an empty catch of the base exception hides failures", - Justification = - "This is the last-resort handler; if the registered handler itself throws there is no further sink to route to without recursing.")] + Justification = "This is the last-resort sink, so rethrowing a handler's own failure would have nowhere to go but back here.")] internal static void ReportUnhandledException(Exception e) { if (e is OperationCanceledException) @@ -56,11 +51,8 @@ internal static void ReportUnhandledException(Exception e) } } - /// Handles unhandled exceptions by writing an error message to the console output. + /// The default handler: writes the exception to as an error. /// The exception that was not handled. Cannot be null. - /// This method is intended to be used as a default handler for unhandled exceptions in - /// an application. It writes the exception details to the standard console output for diagnostic - /// purposes. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static void TraceUnhandledException(Exception exception) => System.Diagnostics.Trace.TraceError("UnhandleException: {0}", exception); diff --git a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs index dc90ed64..3ae4e3f2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs @@ -9,38 +9,31 @@ namespace ReactiveUI.Primitives.Async; -/// Represents an asynchronous observer that processes notifications of type using asynchronous methods. +/// Base class for an asynchronous observer: handles the notification gate, cancellation linking and disposal +/// so a derived sink only has to implement the three Core methods. /// The type of the elements received by the observer. -/// Implement this abstract class to handle asynchronous event streams or push-based data sources, where -/// notifications may arrive concurrently or in rapid succession. The observer provides asynchronous methods for -/// handling new data, errors, and completion signals, and supports proper resource cleanup via asynchronous disposal. -/// Instances are not thread-safe for concurrent notification handling; notifications are processed sequentially, and -/// reentrant calls are detected and reported as unhandled exceptions. +/// One notification runs at a time. A reentrant call from the thread that holds the gate is allowed, but an +/// overlapping call from another thread is a contract violation: the notification is dropped and a +/// goes to . [System.Diagnostics.DebuggerDisplay("WitnessAsync: Disposed = {_disposed}, CallState = {_callState}")] public abstract class WitnessAsync : IObserverAsync, IReentrantAsyncDisposable { - /// Lazily-created CTS that signals disposal to in-flight operations. Stays - /// until someone requests , - /// wires a real token, or - /// is called. Terminal observers (most user-facing sinks) never trigger creation and save - /// the ~72 B per instance the CTS would cost. + /// Signals disposal to in-flight operations; created on the first request for a dispose token, so a + /// terminal sink that never needs one does not pay for it. private CancellationTokenSource? _disposeCts; - /// Disposal latch. Set independently of so the lazy-CTS - /// path can detect post-dispose state before the CTS has been materialized. + /// Disposal latch, set independently of so the lazy path can report + /// post-dispose state with no source in existence. private int _disposed; - /// Set when disposal is requested from within this observer's own in-flight notification - /// (see ). Tells - /// to skip the in-flight-call wait, which would otherwise self-join on the - /// very call awaiting the dispose once its continuation has hopped threads. Monotonic: only ever set, never - /// cleared, since disposal happens once. + /// Set when disposal is requested from inside this observer's own notification, telling + /// to skip the in-flight-call wait that would self-join on the caller once its + /// continuation hops threads. private int _disposeFromNotification; - /// Packed call-state: high 32 bits hold the managed-thread ID of the thread - /// currently inside OnNext/OnError/OnCompleted; low 32 bits hold the in-flight call - /// depth. Updated lock-free via . - /// Replaces a monitor gate + two separate int fields, saving ~24 B per observer. + /// Packed call state: the high 32 bits hold the managed thread ID inside + /// OnNext/OnError/OnCompleted, the low 32 bits the in-flight call depth, updated lock-free through + /// . private long _callState; /// Completion source that is set when all in-flight calls finish after disposal has been requested. @@ -52,12 +45,8 @@ public abstract class WitnessAsync : IObserverAsync, IReentrantAsyncDispos /// Registration created by so the link can be released when the observer disposes. private CancellationTokenRegistration _externalLinkRegistration; - /// - /// The external token last passed to . Cached so - /// can treat it as a - /// fast-path-equal token: its cancellation already propagates to , so combining - /// it again per emission would allocate a redundant linked CTS. - /// + /// The token last passed to , cached because its + /// cancellation reaches anyway, so a call carrying it needs no linked source. private CancellationToken _externalLinkedToken; /// Initializes a new instance of the class. @@ -74,19 +63,19 @@ protected WitnessAsync() protected WitnessAsync(CancellationToken externalLink) => LinkExternalCancellation(externalLink); /// - /// Gets the cancellation token that fires when this observer disposes, so a sibling operator can wire it - /// into a downstream observer's link chain (letting the downstream's hot-path equality check skip the - /// per-emission linked CTS allocation). Lazily materializes the backing . + /// Gets the cancellation token that fires when this observer disposes, for an operator to link into a downstream + /// observer's dispose chain. Reading it creates the backing . /// public CancellationToken InternalDisposedToken => GetOrCreateDisposeCts().Token; /// Gets a value indicating whether this observer has been disposed. internal bool HasDisposed => Volatile.Read(ref _disposed) != 0; - /// Asynchronously processes the next value in the sequence. + /// Delivers a value to , dropping it silently when this observer is disposed, + /// the token is cancelled, or another thread holds the notification gate. /// The value to be processed. /// A cancellation token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. + /// A task that completes once the core handler and its bookkeeping have run. public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) { if (!TryEnterOnSomethingCall(cancellationToken, out var scope)) @@ -120,10 +109,11 @@ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) return OnNextAsyncSlow(core, scope); } - /// Handles an error by attempting to resume processing asynchronously. + /// Routes a non-terminal error to , never propagating a failure back + /// to the producer. /// The exception that triggered the error handling logic. Cannot be null. /// A cancellation token that can be used to cancel the asynchronous operation. - /// A task that represents the asynchronous error handling operation. + /// A task that completes once the error has been handled. public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) { if (!TryEnterOnSomethingCall(cancellationToken, out var scope)) @@ -131,10 +121,8 @@ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellat return default; } - // RouteObserverErrorAsync 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. + // RouteObserverErrorAsync is an async method, so a synchronous throw is captured into the returned + // ValueTask too and surfaces at the await in OnErrorResumeAsyncSlow; a try/catch here is dead code. var core = RouteObserverErrorAsync(error, scope.Token); if (core.IsCompletedSuccessfully) @@ -147,14 +135,11 @@ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellat return OnErrorResumeAsyncSlow(core, scope); } - /// - /// Asynchronously performs completion logic when the operation has finished, handling any finalization or cleanup - /// tasks required. - /// + /// Delivers the terminal result to and then disposes this observer. /// The result of the completed operation, containing information about its outcome. - /// A task that represents the asynchronous completion operation. - /// If an unhandled exception occurs during completion, it is passed to the unhandled exception - /// handler. This method ensures that necessary resources are released after completion. + /// A task that completes once the core handler has run and this observer is torn down. + /// A failure from the core handler goes to ; disposal runs either + /// way. [DebuggerStepThrough] public ValueTask OnCompletedAsync(Result result) { @@ -186,10 +171,9 @@ public ValueTask OnCompletedAsync(Result result) /// Asynchronously releases the resources used by the object. /// A task that represents the asynchronous dispose operation. - /// Call this method to clean up resources when the object is no longer needed. This method is - /// safe to call multiple times; subsequent calls after disposal will have no effect. Any unhandled exceptions that - /// occur during disposal are captured and reported but do not prevent the completion of the dispose - /// operation. + /// Idempotent. Disposal waits out a notification in flight on another thread, and a failure while + /// releasing the source subscription is reported to rather than + /// thrown. [DebuggerStepThrough] public async ValueTask DisposeAsync() { @@ -216,11 +200,8 @@ public ValueTask AssignSourceSubscriptionAsync(IAsyncDisposable? value) => SingleAssignmentDisposableAsync.AssignDisposableAsync(ref _sourceSubscription, value); /// - /// Internal wrapper around so sibling operators - /// (in their SubscribeAsyncCore) can wire an upstream observer's dispose token into this observer's - /// link chain. Combined with the cached fast-path inside - /// , this turns chained - /// operator pipelines into per-emission allocation-free flows. + /// Makes an upstream observer's disposal dispose this observer too, so an operator chain tears down from the top + /// and its emissions need no linked cancellation source. /// /// The upstream observer's dispose token. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -270,9 +251,9 @@ internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out L } } - /// Exits a notification call, decrementing counters and signalling completion if disposal is pending. - /// if the caller should proceed with disposal; if - /// disposal was already signalled to a waiting call. + /// Leaves a notification call, releasing a that is waiting on the last one. + /// when the caller should proceed with disposal itself; + /// when a waiting call was signalled instead. [DebuggerStepThrough] internal bool ExitOnSomethingCall() { @@ -306,7 +287,8 @@ internal bool ExitOnSomethingCall() } } - /// Internal error-resume handler that delegates to and routes unhandled or cancelled errors to the . + /// Runs , sending anything it throws — and the original error when the + /// call is cancelled — to . /// The exception that triggered error handling. /// A cancellation token for the operation. /// A task representing the asynchronous operation. @@ -332,32 +314,27 @@ internal async ValueTask RouteObserverErrorAsync(Exception error, CancellationTo } } - /// Performs asynchronous completion logic when the operation has finished processing the specified result. + /// Handles the sequence's terminal result. Called at most once, before this observer is disposed. /// The result of the operation to be processed during completion. - /// A ValueTask that represents the asynchronous completion operation. + /// A task that completes when the result has been handled. protected abstract ValueTask OnCompletedAsyncCore(Result result); /// - /// Links an external cancellation token into this observer's dispose chain. When - /// is cancelled, the observer disposes — propagating cancellation through the linked token that subclasses - /// receive in their OnNextAsyncCore / OnErrorResumeAsyncCore arguments. This eliminates the - /// need to allocate a per-emission linked . Each observer supports at - /// most one link; calling this method again replaces the previous registration. + /// Links an external cancellation token into this observer's dispose chain: cancelling + /// disposes the observer and cancels the token handed to OnNextAsyncCore and + /// OnErrorResumeAsyncCore. An observer holds at most one link, and a second call replaces it. /// /// The external token whose cancellation should trigger this observer's disposal. [DebuggerStepThrough] protected void LinkExternalCancellation(CancellationToken external) { - // No-op fast path: token can't fire, so the dispose chain doesn't need it. Skip CTS - // materialization entirely — terminal observers that get CancellationToken.None - // pay nothing. + // A token that cannot fire adds nothing to the dispose chain, so skip creating the source entirely. if (!external.CanBeCanceled) { return; } - // External token already cancelled — materialize the CTS only to mark it cancelled - // (so future InternalDisposedToken consumers see the cancelled state). + // A cancelled token needs the source created only to hold the cancelled state for later token requests. if (external.IsCancellationRequested) { Volatile.Write(ref _disposed, 1); @@ -383,16 +360,14 @@ protected void LinkExternalCancellation(CancellationToken external) _externalLinkedToken = external; } - /// Performs application-defined tasks associated with asynchronously releasing unmanaged resources. + /// Performs the observer's asynchronous teardown. /// A task that represents the asynchronous dispose operation. - /// Override this method to provide custom asynchronous resource cleanup logic in a derived - /// class. This method is called by DisposeAsync to perform the actual resource release. + /// An override must call the base implementation: it claims disposal for the first caller, waits out a + /// notification in flight on another thread, and releases the cancellation link and source subscription. [DebuggerStepThrough] protected virtual async ValueTask DisposeAsyncCore() { - // First-disposer wins the race. Lazy-CTS observers set the _disposed flag without - // necessarily materializing a CTS; only callers that previously requested the token - // need the cancellation broadcast. + // First disposer wins; the latch moves whether or not a dispose source exists. if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; @@ -407,18 +382,13 @@ protected virtual async ValueTask DisposeAsyncCore() { TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - // Publish with an interlocked exchange rather than a release store: the re-read below must not - // be reordered ahead of the publish. ExitOnSomethingCall decrements the count under a full-fence - // CAS and only then reads this field, so once the publish is fenced, an Exit that still reads a - // null TCS must already have made its decrement visible to the re-read — one of the two sides - // always signals. A plain Volatile.Write leaves that store/load pair unordered and both sides - // can miss: Exit sees no TCS while this thread still sees a non-zero count, and the await below - // then never completes. + // Interlocked rather than a release store, so the re-read below cannot be reordered ahead of this + // publish. ExitOnSomethingCall decrements under a full-fence CAS and only then reads this field, so + // with the publish fenced, an Exit that reads a null source has made its decrement visible here and + // one of the two sides always signals. Unfenced, both can miss and the await below never completes. _ = Interlocked.Exchange(ref _allCallsCompletedTcs, tcs); - // Re-read after publishing the TCS — Exit may have raced past us and decremented - // the count to zero before our publish became visible. Self-signal so the awaiter - // doesn't deadlock. + // Exit may have dropped the count to zero before the publish became visible, so self-signal. var stateAfter = Volatile.Read(ref _callState); if ((int)stateAfter == 0) { @@ -428,9 +398,7 @@ protected virtual async ValueTask DisposeAsyncCore() allOnSomethingCallsCompleted = tcs.Task; } - // Materialized CTS holders need cancellation propagated; lazy holders skip this step - // (any future InternalDisposedToken request will create a pre-cancelled CTS via - // GetOrCreateDisposeCts's post-disposed branch). + // Only an existing source needs the cancellation broadcast; a later token request builds one pre-cancelled. var cts = Volatile.Read(ref _disposeCts); if (cts is not null) { @@ -440,26 +408,24 @@ protected virtual async ValueTask DisposeAsyncCore() await CompleteDisposeAfterCancelAsync(allOnSomethingCallsCompleted).ConfigureAwait(false); } - /// Handles an error by providing an asynchronous mechanism to resume execution after an exception occurs. + /// Handles a non-terminal error; the sequence may deliver more values afterwards. /// The exception that triggered the error handling logic. Cannot be null. /// A cancellation token that can be used to cancel the asynchronous error handling operation. - /// A ValueTask that represents the asynchronous operation of resuming execution after the error. - /// Override this method to implement custom error recovery or resumption logic in derived - /// classes. The method is called when an error occurs and allows the operation to continue or perform cleanup - /// asynchronously. + /// A task that completes when the error has been handled. + /// An exception thrown from an override is reported to instead of + /// reaching the producer. protected abstract ValueTask OnErrorResumeAsyncCore(Exception error, CancellationToken cancellationToken); /// Processes the next value in the asynchronous sequence. /// The value to be processed. /// A cancellation token that can be used to cancel the asynchronous operation. - /// A ValueTask that represents the asynchronous operation. + /// A task that completes when the value has been handled. + /// An exception thrown from an override is fed to , so the sequence + /// survives a failed value; a cancellation is swallowed. protected abstract ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken); - /// - /// Finishes the teardown after this caller won the cancellation race. Separated from - /// so the race-loser branch is just the absence of this - /// call, with no return; sequence point to mark uncovered. - /// + /// Awaits any in-flight notifications, then releases the cancellation link, the dispose source and the + /// source subscription. /// Optional gate awaited for in-flight On* calls. /// A task representing the asynchronous teardown. private async ValueTask CompleteDisposeAfterCancelAsync(Task? allOnSomethingCallsCompleted) @@ -486,11 +452,9 @@ private async ValueTask CompleteDisposeAfterCancelAsync(Task? allOnSomethingCall } } - /// Returns the dispose task on the race-winner path ( - /// reports the last in-flight On* call just exited), or a completed default - /// otherwise. Isolated from coverage because the race-winner branch is only reachable when a - /// concurrent set the in-flight gate while this On* call was running. - /// The dispose task on race-winner, or default otherwise. + /// Chains into when this exiting call owns disposal, which happens only when a + /// concurrent raised the in-flight gate while the notification was running. + /// The dispose task when this caller owns disposal; otherwise a completed task. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private ValueTask CompleteOrChainDispose() => ExitOnSomethingCall() ? DisposeAsync() : default; @@ -521,9 +485,8 @@ private async ValueTask OnNextAsyncSlow(ValueTask core, LinkedTokenScope scope) } /// - /// Async continuation for when threw synchronously. - /// Routes the error through off the fast path so the - /// caller-visible stays state-machine free in the common case. + /// Routes a synchronous throw from through , + /// out of line so itself needs no state machine. /// /// The exception thrown by the core. /// The linked-token scope to release on completion. @@ -583,10 +546,9 @@ private async ValueTask OnCompletedAsyncSlow(ValueTask core, LinkedTokenScope sc } } - /// Builds the for the current call, allocating a - /// linked CTS only when the caller token isn't already one of the fast-path equivalents - /// (, our own dispose token if materialized, or the - /// upstream token already linked via ). + /// Builds the for one call, allocating a linked source only when the + /// caller's token is none of , this observer's own dispose token, or the token + /// linked through — each of which cancels the call anyway. /// The caller-supplied cancellation token. /// A scope whose Token drives the in-flight call's cancellation. private LinkedTokenScope BuildLinkedTokenScope(CancellationToken cancellationToken) @@ -604,19 +566,14 @@ private LinkedTokenScope BuildLinkedTokenScope(CancellationToken cancellationTok return new(linkedCts, linkedCts.Token); } - /// Returns the existing or lazily creates it. If - /// was set before this call (the observer was disposed before any token was requested), the - /// freshly-created CTS is cancelled immediately so its - /// matches the post-dispose state callers expect. - /// The dispose CTS, freshly cancelled if disposal was already signaled. + /// Returns the dispose source, creating it on first request. + /// The dispose source, cancelled when this observer is disposed. private CancellationTokenSource GetOrCreateDisposeCts() => Volatile.Read(ref _disposeCts) ?? MaterializeDisposeCts(); - /// Creates and publishes the dispose CTS on first request, discarding the freshly-created instance - /// if another thread published one first, and pre-cancelling it when disposal was already signaled. - /// The published dispose CTS. - /// The compare-exchange-lost branch is only reachable when two threads materialize the CTS - /// concurrently; isolated here and excluded from coverage as race-only. + /// Creates and publishes the dispose source, discarding this instance when another thread wins the + /// publish, and cancelling it when the observer is disposed before any token was requested. + /// The published dispose source. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private CancellationTokenSource MaterializeDisposeCts() { @@ -637,9 +594,8 @@ private CancellationTokenSource MaterializeDisposeCts() } /// - /// A lightweight scope that wraps an optional and exposes the - /// effective . When no linked source is needed (e.g. the caller token - /// is ), the scope avoids allocating a linked CTS entirely. + /// Pairs one notification call's effective cancellation token with the linked source backing it, if one was + /// needed, so disposing the scope releases that source. /// /// The linked CTS to dispose, or if no allocation was needed. /// The effective cancellation token for the notification call. diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs index 9b9b729c..ff33b05b 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs @@ -8,9 +8,8 @@ namespace ReactiveUI.Primitives.Advanced; /// /// Single-source sink that folds every observed value through an immutable value-type -/// and emits the aggregate result once the source completes. The accumulator is advanced functionally through a -/// constrained (devirtualized, allocation-free) call, so each concrete aggregate operator shares this one -/// implementation without a base class or per-value indirection. +/// and emits the aggregate result once the source completes. The accumulator is advanced functionally: each value +/// yields a replacement accumulator rather than mutating the current one. /// /// The observed value type. /// The terminal result type. @@ -52,7 +51,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, _aggregator.Result, this, ref _done); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AllPredicateWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/AllPredicateWitness.cs index e15937c2..a0f06d89 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AllPredicateWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AllPredicateWitness.cs @@ -60,7 +60,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => EmitCompleted(true); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AnyPredicateWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/AnyPredicateWitness.cs index 85ebf3dd..c12d0b8a 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AnyPredicateWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AnyPredicateWitness.cs @@ -44,7 +44,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, false, this, ref _done); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AnyWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/AnyWitness.cs index b7f1d936..bab7f8a1 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AnyWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AnyWitness.cs @@ -36,7 +36,7 @@ public sealed class AnyWitness : IObserver, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, false, this, ref _done); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AppendDelegateWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/AppendDelegateWitness.cs index 95909734..fca3f0c7 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AppendDelegateWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AppendDelegateWitness.cs @@ -71,7 +71,7 @@ public void OnCompleted() } } - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AppendWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/AppendWitness.cs index 2b333565..2efdcddc 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AppendWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AppendWitness.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// Observer for append. +/// Sink that forwards source values and emits one trailing value before completing. /// The source value type. /// The downstream observer. /// The appended value. @@ -44,7 +44,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, _value, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs index 688f06f4..679e0776 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs @@ -36,10 +36,10 @@ public IDisposable Subscribe(IObserver observer) /// Drives a single subscription's pump and coordinates single-owner enumerator disposal. /// - /// Enumerator disposal is single-owner via an interlocked claim: whichever of the pump's - /// finally and wins disposes the enumerator exactly once. The disposer - /// disposes immediately when it wins, so a non-cooperative enumerator (one that ignores cancellation - /// in ) is still torn down promptly without waiting on it. + /// Enumerator disposal is single-owner through an interlocked claim: whichever of the pump's + /// finally and wins the claim disposes the enumerator exactly once. The + /// winner disposes without awaiting , so an enumerator + /// that ignores cancellation is torn down promptly rather than at its own pace. /// private sealed class Subscription : IDisposable { @@ -91,12 +91,9 @@ public void Dispose() } catch (ObjectDisposedException) { - // The pump already completed and disposed the cancellation source. + // The pump completed and disposed the cancellation source. } - // Dispose the enumerator immediately when this path wins ownership, rather than waiting - // on MoveNextAsync, so a non-cooperative enumerator (one that ignores cancellation) is - // still torn down promptly. The pump's finally disposes it only if it wins the race. if (TryClaimEnumerator(out var enumerator)) { FireAndForgetDispose(enumerator); @@ -107,13 +104,10 @@ public void Dispose() /// Disposes an enumerator without surfacing the resulting task to the caller. /// The enumerator to dispose. - /// - /// The disposer cannot await, so disposal runs detached; the local method observes the task to - /// prevent unobserved-fault tear-downs, swallowing the raised - /// by enumerators that only permit disposal from the enumeration path. - /// private static void FireAndForgetDispose(IAsyncEnumerator enumerator) { + // Disposal runs detached because IDisposable.Dispose cannot await it; the local method keeps + // the resulting task observed. _ = ObserveAsync(enumerator); static async Task ObserveAsync(IAsyncEnumerator enumerator) @@ -155,8 +149,8 @@ private async Task PumpAsync() Volatile.Write(ref _enumerator, enumerator); while (!_cts.IsCancellationRequested && await enumerator.MoveNextAsync().ConfigureAwait(false)) { - // Re-check after the await: disposal may have torn down the observer while the - // element was in flight, so a buffered value must not reach a stopped observer. + // Re-check after the await: disposal may have torn the subscription down while the + // element was in flight, and a buffered value must not reach a stopped observer. if (_cts.IsCancellationRequested) { break; diff --git a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs index d134b943..8a0fd093 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs @@ -47,7 +47,6 @@ public void OnNext(T value) var buffer = _buffer; if (idx == 0) { - // Window starts: allocate exactly one array of the known window size. buffer = new T[_count]; _buffer = buffer; } @@ -65,13 +64,12 @@ public void OnNext(T value) return; } - // The window is full: hand the buffer over and reset to the skip *before* the hand-off. The - // observer may throw, and it must never be able to leave this sink holding an index into a - // buffer it has already released — the next value would index into null. + // Reset to the skip *before* the hand-off: the observer may throw, and the sink must never be left + // holding an index into a buffer it has released, because the next value would index into null. _buffer = null; _index = 0 - _skip; - // The window is full, so the array is exactly the right size; emit it directly. + // A full window is exactly the right size, so it needs no trimming. Emit(buffer!); } @@ -114,7 +112,7 @@ public void OnCompleted() } } - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); @@ -122,8 +120,8 @@ public void OnCompleted() /// public void Dispose() { - // Latching here is what makes the sink terminal on every teardown path, including the one taken - // when the downstream observer throws out of Emit: a source that ignores disposal is then a no-op. + // Latching here makes the sink terminal on every teardown path, including disposal from Emit when + // the downstream observer throws, so a source that ignores disposal cannot push another value in. Volatile.Write(ref _done, 1); SinkSubscription.Dispose(ref _subscription); } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/CallbackWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/CallbackWitness.cs index a7f772cc..d904bae3 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/CallbackWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/CallbackWitness.cs @@ -47,7 +47,6 @@ public CallbackWitness(Action onNext, Action? onError, ActionRethrows the supplied exception without losing its stack information. /// The exception to rethrow. - /// Excluded from coverage: the unreachable sequence point after rethrow cannot be credited by cobertura. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private static void Rethrow(Exception error) => ExceptionDispatchInfo.Capture(error).Throw(); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/CollectArrayWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/CollectArrayWitness.cs index 5f3b1c8b..0e4cbe0d 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/CollectArrayWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/CollectArrayWitness.cs @@ -36,7 +36,7 @@ public sealed class CollectArrayWitness : IObserver, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, [.. _values], this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/CollectListWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/CollectListWitness.cs index 0ad6e09c..681ddc59 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/CollectListWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/CollectListWitness.cs @@ -36,7 +36,7 @@ public sealed class CollectListWitness : IObserver, IDisposable [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, _values, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ContainsWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/ContainsWitness.cs index 971081b6..6e81eb35 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ContainsWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ContainsWitness.cs @@ -64,7 +64,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => EmitCompleted(false); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/DefaultIfEmptyWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/DefaultIfEmptyWitness.cs index e3bd8665..00dfe5c8 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/DefaultIfEmptyWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/DefaultIfEmptyWitness.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// Observer for default-if-empty. +/// Sink that forwards source values, emitting a single fallback value when the source completes empty. /// The source value type. /// The downstream observer. /// Value emitted for an empty source. @@ -62,7 +62,7 @@ public void OnCompleted() } } - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/DisposedMarker.cs b/src/ReactiveUI.Primitives.Core/Advanced/DisposedMarker.cs index 2173c853..4b6b301c 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/DisposedMarker.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/DisposedMarker.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// Disposable marker for disposed sinks. +/// No-op disposable used as the sentinel value for a released subscription slot. public sealed class DisposedMarker : IDisposable { /// diff --git a/src/ReactiveUI.Primitives.Core/Advanced/DisposedWitness{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/DisposedWitness{T}.cs index 45f79723..551ac196 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/DisposedWitness{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/DisposedWitness{T}.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// Observer that rejects every notification because the subscription has already been disposed. +/// Observer that throws from every notification, standing in for a disposed subscription. /// The observed value type. public sealed class DisposedWitness : IObserver { diff --git a/src/ReactiveUI.Primitives.Core/Advanced/DistinctByCountAggregator.cs b/src/ReactiveUI.Primitives.Core/Advanced/DistinctByCountAggregator.cs index ec35c879..e12843b4 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/DistinctByCountAggregator.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/DistinctByCountAggregator.cs @@ -14,7 +14,7 @@ public readonly record struct /// The selector that projects each value to its distinctness key. private readonly Func _keySelector; - /// The set of keys that have already been observed. + /// The set of keys observed so far. private readonly HashSet _seen; /// Initializes a new instance of the struct. @@ -27,7 +27,7 @@ public DistinctByCountAggregator(Func keySelector, IEqualityComparerInitializes a new instance of the struct. /// The key selector. - /// The set of keys that have already been observed. + /// The set of keys observed so far. /// The current accumulated count. private DistinctByCountAggregator(Func keySelector, HashSet seen, int result) { diff --git a/src/ReactiveUI.Primitives.Core/Advanced/DistinctByLongCountAggregator.cs b/src/ReactiveUI.Primitives.Core/Advanced/DistinctByLongCountAggregator.cs index 2dd240f2..578fe421 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/DistinctByLongCountAggregator.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/DistinctByLongCountAggregator.cs @@ -14,7 +14,7 @@ public readonly record struct /// The selector that projects each value to its distinctness key. private readonly Func _keySelector; - /// The set of keys that have already been observed. + /// The set of keys observed so far. private readonly HashSet _seen; /// Initializes a new instance of the struct. @@ -27,7 +27,7 @@ public DistinctByLongCountAggregator(Func keySelector, IEqualityCompare /// Initializes a new instance of the struct. /// The key selector. - /// The set of keys that have already been observed. + /// The set of keys observed so far. /// The current accumulated count. private DistinctByLongCountAggregator(Func keySelector, HashSet seen, long result) { diff --git a/src/ReactiveUI.Primitives.Core/Advanced/DistinctByWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/DistinctByWitness.cs index 62f8ab8b..743f60c9 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/DistinctByWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/DistinctByWitness.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// Observer for distinct-by. +/// Sink that forwards the first value seen for each key the selector produces. /// The source value type. /// The key type. [System.Diagnostics.DebuggerDisplay("DistinctByWitness: Done = {_done}, SeenKeys = {_seen.Count}")] @@ -65,7 +65,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this, ref _done); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/DistinctWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/DistinctWitness.cs index 261734cc..090aa5f8 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/DistinctWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/DistinctWitness.cs @@ -9,14 +9,14 @@ namespace ReactiveUI.Primitives.Advanced; /// Sink that forwards the first occurrence of each value. /// The value type. /// The downstream observer. -/// The set used to track already-observed values. +/// The set that records which values have been observed. [System.Diagnostics.DebuggerDisplay("DistinctWitness: Seen = {_seen.Count}, Subscription = {_subscription}")] public sealed class DistinctWitness(IObserver observer, HashSet seen) : IObserver, IDisposable { /// The downstream observer. private readonly IObserver _observer = observer; - /// The set of values already observed. + /// The set of values observed so far. private readonly HashSet _seen = seen; /// The upstream subscription. @@ -49,7 +49,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/EmptyWitness{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/EmptyWitness{T}.cs index 7e91ee30..1633f09a 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/EmptyWitness{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/EmptyWitness{T}.cs @@ -67,17 +67,17 @@ public EmptyWitness(Action onNext, Action onError, Action onComple _onCompleted = onCompleted; } - /// Calls the action implementing . + /// Invokes the completion callback, doing nothing when none was supplied. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => (_onCompleted ?? nop)(); - /// Calls the action implementing . - /// Error notification. + /// Invokes the error callback, swallowing the error when none was supplied. + /// The terminal error. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnError(Exception error) => (_onError ?? nope)(error); - /// Calls the action implementing . - /// Value notification. + /// Invokes the value callback. + /// The observed value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(T value) => _onNext(value); } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/FoldWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/FoldWitness.cs index 604e6d3d..6a390231 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/FoldWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/FoldWitness.cs @@ -53,7 +53,7 @@ public void OnNext(TSource value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/FromEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/FromEnumerableSignal{T}.cs index 61c52d97..d2b6d180 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/FromEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/FromEnumerableSignal{T}.cs @@ -32,14 +32,15 @@ public FromEnumerableSignal(IEnumerable values, CancellationToken cancellatio _cancellationToken = cancellationToken; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// . + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The subscription. + /// Enumerates the source into on the calling thread, completing it at the end. + /// The observer to notify. + /// An empty disposable; enumeration has finished by the time this returns. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -80,11 +81,12 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The subscription. + /// Enumerates the source into the callbacks on the calling thread, invoking at the end. + /// Invoked for each value. + /// Never invoked; enumeration faults propagate to the caller. + /// Invoked once the source is drained, unless cancellation stops enumeration first. + /// An empty disposable; enumeration has finished by the time this returns. + /// or is . public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { ArgumentExceptionHelper.ThrowIfNull(onNext); @@ -110,9 +112,9 @@ public IDisposable Subscribe(Action onNext, Action onError, Action return EmptyDisposable.Instance; } - /// Attempts to expose the backing sequence when it is already indexable and cannot be cancelled. - /// The indexable values. - /// when values can be read without enumeration allocations. + /// Exposes the backing sequence directly when it is an indexable list and no cancellation token was supplied. + /// The indexable values, or an empty list when the sequence cannot be exposed. + /// when the values can be read without enumerating. public bool TryGetReadOnlyValues(out IReadOnlyList values) { if (_cancellationToken.CanBeCanceled) diff --git a/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs b/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs index d14a4926..a7f1fd99 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs @@ -7,12 +7,12 @@ namespace ReactiveUI.Primitives.Advanced; /// /// An immutable value-type accumulator for a single-source aggregate sink: each folds a value /// into the running state and returns the next accumulator, while yields the terminal value. -/// Implemented as a so -/// can advance it through a constrained, devirtualized, allocation-free call rather than an interface or delegate dispatch. +/// Implement it as a so +/// can fold without allocating per value. /// /// The observed value type. /// The terminal result type. -/// The implementing accumulator type, returned by so folding stays allocation-free. +/// The implementing accumulator type, returned by . public interface IAggregator where TSelf : IAggregator { diff --git a/src/ReactiveUI.Primitives.Core/Advanced/IInlineSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/IInlineSignal{T}.cs index b159ab28..5f52a7d2 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/IInlineSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/IInlineSignal{T}.cs @@ -4,14 +4,14 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the IInlineSignal interface. -/// The T type. +/// A signal that accepts callbacks directly, so a subscriber needs no intermediate observer. +/// The value type. public interface IInlineSignal : IObservable { - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Subscribes the supplied callbacks to the signal. + /// Invoked for each value. + /// Invoked with the terminal error. + /// Invoked when the signal completes. + /// A handle that detaches the callbacks when disposed. IDisposable Subscribe(Action onNext, Action onError, Action onCompleted); } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/IRequireCurrentThread.cs b/src/ReactiveUI.Primitives.Core/Advanced/IRequireCurrentThread.cs index cb88ea51..7a4a537f 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/IRequireCurrentThread.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/IRequireCurrentThread.cs @@ -4,13 +4,11 @@ namespace ReactiveUI.Primitives.Advanced; -/// Marks a sequencer that must run on the current thread. -/// The Type. +/// A signal that reports whether it has to be subscribed to from the calling thread. +/// The value type. public interface IRequireCurrentThread : IObservable { - /// Determines whether [is required subscribe on current thread]. - /// - /// true if [is required subscribe on current thread]; otherwise, false. - /// + /// Indicates whether subscription has to happen on the calling thread. + /// when the signal is bound to the subscribing thread. bool IsRequiredSubscribeOnCurrentThread(); } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/IgnoreValuesWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/IgnoreValuesWitness.cs index 591687c1..0f449289 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/IgnoreValuesWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/IgnoreValuesWitness.cs @@ -35,7 +35,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs index 2e2ee0ba..3a9848db 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs @@ -7,26 +7,27 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the ImmediateReturnSignal class. -/// The T type. +/// Signal that emits one value and completes synchronously inside Subscribe. +/// The value type. [System.Diagnostics.DebuggerDisplay("ImmediateReturnSignal: Value = {_value}")] public sealed class ImmediateReturnSignal : IRequireCurrentThread, IInlineSignal { - /// Stores state for the signal implementation. + /// The value emitted to every subscriber. private readonly T _value; /// Initializes a new instance of the class. /// The value. public ImmediateReturnSignal(T value) => _value = value; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits the value to and completes it before returning. + /// The observer to notify. + /// An empty disposable; the signal has finished by the time this returns. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -36,11 +37,11 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes with the value, then , before returning. + /// Invoked with the value. + /// Never invoked. + /// Invoked after the value. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { onNext(_value); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs index 7ca197b6..8c80ae1c 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs @@ -7,8 +7,8 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the immediate Throw signal fast path. -/// The T type. +/// Signal that delivers a terminal error synchronously inside Subscribe. +/// The value type. [System.Diagnostics.DebuggerDisplay("ImmediateThrowSignal: Error = {_error}")] public sealed class ImmediateThrowSignal : IRequireCurrentThread, IInlineSignal { @@ -19,14 +19,15 @@ public sealed class ImmediateThrowSignal : IRequireCurrentThread, IInlineS /// The terminal error. public ImmediateThrowSignal(Exception error) => _error = error; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Faults with the stored error before returning. + /// The observer to fault. + /// An empty disposable; the signal has finished by the time this returns. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -35,11 +36,11 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes with the stored error before returning. + /// Never invoked. + /// Invoked with the stored error. + /// Never invoked. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { onError(_error); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs index 2b97c2de..bacd6d05 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs @@ -7,12 +7,11 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the ImmutableEmptySignal class. -/// The T type. +/// Signal that completes synchronously inside Subscribe without emitting a value. +/// The value type. public sealed class ImmutableEmptySignal : IRequireCurrentThread, IInlineSignal { - /// Executes the new operation. - /// The result. + /// The shared instance; the signal carries no per-subscription state. public static readonly ImmutableEmptySignal Instance = new(); /// Initializes a new instance of the class. @@ -20,14 +19,15 @@ private ImmutableEmptySignal() { } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Completes before returning. + /// The observer to complete. + /// An empty disposable; the signal has finished by the time this returns. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -36,11 +36,11 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes before returning. + /// Never invoked. + /// Never invoked. + /// Invoked before this method returns. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { onCompleted(); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableNeverSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableNeverSignal{T}.cs index 00f13ef7..04b57410 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableNeverSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableNeverSignal{T}.cs @@ -7,22 +7,21 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the ImmutableNeverSignal class. -/// The T type. +/// Signal that never emits a value and never terminates. +/// The value type. public sealed class ImmutableNeverSignal : IRequireCurrentThread { - /// Executes the new operation. - /// The result. + /// The shared instance; the signal carries no per-subscription state. public static readonly ImmutableNeverSignal Instance = new(); - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Accepts the observer without ever notifying it. + /// The observer, which receives nothing. + /// An empty disposable; there is no subscription to release. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => EmptyDisposable.Instance; diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnFalseSignal.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnFalseSignal.cs index b6f1d90a..aece6b9b 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnFalseSignal.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnFalseSignal.cs @@ -7,11 +7,10 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the ImmutableReturnFalseSignal class. +/// Signal that emits and completes synchronously inside Subscribe. public sealed class ImmutableReturnFalseSignal : IRequireCurrentThread, IInlineSignal { - /// Executes the new operation. - /// The result. + /// The shared instance; the signal carries no per-subscription state. public static readonly ImmutableReturnFalseSignal Instance = new(); /// Initializes a new instance of the class. @@ -19,14 +18,14 @@ private ImmutableReturnFalseSignal() { } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits to and completes it before returning. + /// The observer to notify. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(IObserver observer) { observer.OnNext(false); @@ -34,11 +33,11 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes with , then . + /// Invoked with . + /// Never invoked. + /// Invoked after the value. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { onNext(false); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnInt32Signal.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnInt32Signal.cs index 1764e0db..b113386a 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnInt32Signal.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnInt32Signal.cs @@ -7,18 +7,17 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the ImmutableReturnInt32Signal class. +/// Signal that emits a single and completes synchronously inside Subscribe. [System.Diagnostics.DebuggerDisplay("ImmutableReturnInt32Signal: Value = {_x}")] public sealed class ImmutableReturnInt32Signal : IRequireCurrentThread, IInlineSignal { - /// Stores state for the signal implementation. + /// The lowest value with a cached instance. private const int MinCachedValue = -1; - /// Stores state for the signal implementation. + /// The highest value with a cached instance. private const int MaxCachedValue = 9; - /// Executes the new operation. - /// The result. + /// The instances cached for values from to . private static readonly ImmutableReturnInt32Signal[] Caches = [ new(-1), @@ -34,29 +33,29 @@ public sealed class ImmutableReturnInt32Signal : IRequireCurrentThread, IIn new(9) ]; - /// Stores state for the signal implementation. + /// The value emitted to every subscriber. private readonly int _x; /// Initializes a new instance of the class. - /// The x value. + /// The value to emit. public ImmutableReturnInt32Signal(int x) => _x = x; - /// Executes the GetInt32Signals operation. - /// The x value. - /// The result. + /// Returns a signal emitting , reusing a cached instance for small values. + /// The value to emit. + /// A signal that emits and completes. public static IObservable GetInt32Signals(int x) => x is >= MinCachedValue and <= MaxCachedValue ? Caches[x - MinCachedValue] : new ImmediateReturnSignal(x); - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits the value to and completes it before returning. + /// The observer to notify. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(IObserver observer) { observer.OnNext(_x); @@ -64,11 +63,11 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes with the value, then . + /// Invoked with the value. + /// Never invoked. + /// Invoked after the value. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { onNext(_x); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnTrueSignal.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnTrueSignal.cs index 82dcce97..12344a36 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnTrueSignal.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableReturnTrueSignal.cs @@ -7,11 +7,10 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the ImmutableReturnTrueSignal class. +/// Signal that emits and completes synchronously inside Subscribe. public sealed class ImmutableReturnTrueSignal : IRequireCurrentThread, IInlineSignal { - /// Executes the new operation. - /// The result. + /// The shared instance; the signal carries no per-subscription state. public static readonly ImmutableReturnTrueSignal Instance = new(); /// Initializes a new instance of the class. @@ -19,14 +18,14 @@ private ImmutableReturnTrueSignal() { } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits to and completes it before returning. + /// The observer to notify. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(IObserver observer) { observer.OnNext(true); @@ -34,11 +33,11 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes with , then . + /// Invoked with . + /// Never invoked. + /// Invoked after the value. + /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { onNext(true); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/KeepNotNullWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/KeepNotNullWitness.cs index d1c7739b..a13a15d7 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/KeepNotNullWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/KeepNotNullWitness.cs @@ -49,7 +49,7 @@ public void OnNext(T? value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/KeepTypeWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/KeepTypeWitness.cs index 62d6b954..27ee1fc8 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/KeepTypeWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/KeepTypeWitness.cs @@ -48,7 +48,7 @@ public void OnNext(object? value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/RangeConcatSignal.cs b/src/ReactiveUI.Primitives.Core/Advanced/RangeConcatSignal.cs index 1a554c22..b0344896 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/RangeConcatSignal.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/RangeConcatSignal.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// Concatenates synchronous integer ranges without outer observable/coordinator overhead. +/// Signal that emits several integer ranges back to back, synchronously inside Subscribe. [System.Diagnostics.DebuggerDisplay("RangeConcatSignal: Ranges = {_ranges.Length}")] public sealed class RangeConcatSignal : IRequireCurrentThread, IInlineSignal { diff --git a/src/ReactiveUI.Primitives.Core/Advanced/RangeSignal.cs b/src/ReactiveUI.Primitives.Core/Advanced/RangeSignal.cs index a398359c..84cf8b77 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/RangeSignal.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/RangeSignal.cs @@ -7,13 +7,13 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the RangeSignal class. +/// Signal that emits a contiguous run of integers, synchronously inside Subscribe. [System.Diagnostics.DebuggerDisplay("RangeSignal: Start = {Start}, Count = {Count}")] public sealed class RangeSignal : IRequireCurrentThread, IInlineSignal { /// Initializes a new instance of the class. - /// The start value. - /// The count value. + /// The first value emitted. + /// The number of values emitted. public RangeSignal(int start, int count) { Start = start; @@ -26,14 +26,15 @@ public RangeSignal(int start, int count) /// Gets the number of values emitted by the range. public int Count { get; } - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits the whole range to and completes it before returning. + /// The observer to notify. + /// An empty disposable; the range has finished by the time this returns. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -47,11 +48,12 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Emits the whole range to , then invokes . + /// Invoked for each value in the range. + /// Never invoked. + /// Invoked after the last value. + /// An empty disposable; the range has finished by the time this returns. + /// is . public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { ArgumentExceptionHelper.ThrowIfNull(onNext); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/RangeZipSignal{TResult}.cs b/src/ReactiveUI.Primitives.Core/Advanced/RangeZipSignal{TResult}.cs index 78346985..aa3c6277 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/RangeZipSignal{TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/RangeZipSignal{TResult}.cs @@ -7,21 +7,21 @@ namespace ReactiveUI.Primitives.Advanced; -/// Zips two synchronous integer ranges without coordinator queues. +/// Signal that pairs two integer ranges position by position, synchronously inside Subscribe. /// The result value type. [System.Diagnostics.DebuggerDisplay("RangeZipSignal: Count = {_count}, LeftStart = {_leftStart}, RightStart = {_rightStart}")] public sealed class RangeZipSignal : IRequireCurrentThread, IInlineSignal { - /// Stores state for the signal implementation. + /// The first value of the left range. private readonly int _leftStart; - /// Stores state for the signal implementation. + /// The first value of the right range. private readonly int _rightStart; - /// Stores state for the signal implementation. + /// The number of pairs emitted, the shorter of the two ranges. private readonly int _count; - /// Stores state for the signal implementation. + /// The projection applied to each pair. private readonly Func _selector; /// Initializes a new instance of the class. @@ -36,14 +36,15 @@ public RangeZipSignal(RangeSignal left, RangeSignal right, FuncExecutes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits every projected pair to and completes it before returning. + /// The observer to notify. + /// An empty disposable; both ranges are drained by the time this returns. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -57,11 +58,12 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Emits every projected pair to , then invokes . + /// Invoked for each projected pair. + /// Never invoked. + /// Invoked after the last pair. + /// An empty disposable; both ranges are drained by the time this returns. + /// is . public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { ArgumentExceptionHelper.ThrowIfNull(onNext); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ReduceWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/ReduceWitness.cs index e863145c..6c975474 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ReduceWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ReduceWitness.cs @@ -41,7 +41,7 @@ public sealed class ReduceWitness( [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, _current, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/RepeatSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/RepeatSignal{T}.cs index db15364c..a96805cb 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/RepeatSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/RepeatSignal{T}.cs @@ -7,27 +7,28 @@ namespace ReactiveUI.Primitives.Advanced; -/// Represents the RepeatSignal class. -/// The T type. -/// The value. -/// The count value. +/// Signal that emits one value a fixed number of times, synchronously inside Subscribe. +/// The value type. +/// The value to emit. +/// The number of times to emit the value. [System.Diagnostics.DebuggerDisplay("RepeatSignal: Value = {_value}, Count = {_count}")] public sealed class RepeatSignal(T value, int count) : IRequireCurrentThread, IInlineSignal { - /// Stores state for the signal implementation. + /// The value to emit. private readonly T _value = value; - /// Stores state for the signal implementation. + /// The number of times to emit the value. private readonly int _count = count; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. - /// The result. + /// Indicates whether subscription has to happen on the calling thread. + /// Always . [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsRequiredSubscribeOnCurrentThread() => false; - /// Executes the Subscribe operation. - /// The observer value. - /// The result. + /// Emits the value the configured number of times to , then completes it. + /// The observer to notify. + /// An empty disposable; the signal has finished by the time this returns. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -41,11 +42,12 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the Subscribe operation. - /// The onNext value. - /// The onError value. - /// The onCompleted value. - /// The result. + /// Invokes with the value the configured number of times, then . + /// Invoked once per repetition. + /// Never invoked. + /// Invoked after the last repetition. + /// An empty disposable; the signal has finished by the time this returns. + /// is . public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { ArgumentExceptionHelper.ThrowIfNull(onNext); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs b/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs index 258dfcd1..d4448eb4 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs @@ -7,17 +7,16 @@ namespace ReactiveUI.Primitives.Advanced; /// -/// Shared single-upstream-subscription management for sink observers. Operating on a caller-owned -/// field through a parameter lets each sink implement -/// directly — with no shared base class, and therefore no virtual-dispatch -/// overhead on the hot notification path — while still sharing the assign-once / dispose-once teardown. +/// Assign-once, dispose-once management of a sink's single upstream subscription. Each helper takes the +/// caller-owned field by , so a sink can implement +/// directly without deriving from a shared base class. /// public static class SinkSubscription { /// Sentinel stored once a sink is disposed so any late subscription is torn down immediately. private static readonly IDisposable DisposedSentinel = new DisposedMarker(); - /// Assigns the upstream subscription, disposing it immediately if the sink already holds one or has been disposed. + /// Assigns the upstream subscription, disposing the incoming value when the field holds a subscription or the sink has been disposed. /// The caller-owned subscription field. /// The upstream subscription to assign. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs b/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs index ba238925..160d0185 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs @@ -5,10 +5,9 @@ namespace ReactiveUI.Primitives.Advanced; /// -/// Shared terminal-forwarding helpers for single-source sink observers. Each sink computes a -/// single result and forwards it downstream exactly once, disposing itself afterwards. Centralizing the -/// forward-then-dispose using scope — and the done latch that guards it — keeps the -/// individual sinks to their distinguishing accumulation logic and removes the otherwise-identical terminal boilerplate. +/// Terminal-forwarding helpers for single-source sink observers: deliver one terminal notification +/// downstream and dispose the sink afterwards, optionally latching a caller-owned done flag so only +/// the first terminal call reaches the observer. /// public static class SinkTerminal { @@ -30,7 +29,7 @@ public static void Fault(IObserver observer, Exception error, /// The downstream observer. /// The error to forward. /// The sink to dispose once the error has been delivered. - /// The caller-owned terminal latch; ignored once already set. + /// The caller-owned terminal latch; the call is a no-op when it is set. public static void Fault(IObserver observer, Exception error, IDisposable sink, ref bool done) { if (done) @@ -61,7 +60,7 @@ public static void Complete(IObserver observer, TResult value, /// The downstream observer. /// The single terminal value to emit. /// The sink to dispose once completion has been delivered. - /// The caller-owned terminal latch; ignored once already set. + /// The caller-owned terminal latch; the call is a no-op when it is set. public static void Complete(IObserver observer, TResult value, IDisposable sink, ref bool done) { if (done) @@ -89,7 +88,7 @@ public static void Complete(IObserver observer, IDisposable si /// The downstream result type. /// The downstream observer. /// The sink to dispose once completion has been delivered. - /// The caller-owned terminal latch; ignored once already set. + /// The caller-owned terminal latch; the call is a no-op when it is set. public static void Complete(IObserver observer, IDisposable sink, ref bool done) { if (done) diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SkipWhileWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/SkipWhileWitness.cs index daf4f90e..d652e8de 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SkipWhileWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SkipWhileWitness.cs @@ -19,7 +19,7 @@ public sealed class SkipWhileWitness(IObserver observer, Func pre /// The predicate that determines whether to keep skipping values. private readonly Func _predicate = predicate; - /// A value indicating whether the skipping phase is still active. + /// A value indicating whether the sink is in its skipping phase. private bool _skipping = true; /// The upstream subscription. @@ -53,7 +53,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SkipWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/SkipWitness.cs index 72a9610b..afff21d4 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SkipWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SkipWitness.cs @@ -50,7 +50,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/StatefulWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/StatefulWitness.cs index 74313c37..036deec5 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/StatefulWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/StatefulWitness.cs @@ -58,7 +58,6 @@ public StatefulWitness( /// Rethrows the supplied exception without losing its stack information. /// The exception to rethrow. /// Unused callback state. - /// Excluded from coverage: the unreachable sequence point after rethrow cannot be credited by cobertura. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private static void Rethrow(Exception error, TState state) { diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs b/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs index 15c992b0..d2f17bc4 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs @@ -28,7 +28,7 @@ public static void Release(ref IDisposable? slot) current.Dispose(); } - /// Stores a subscription into an empty slot, disposing it instead if the slot is already released. + /// Stores a subscription into an empty slot, disposing it instead when the slot is occupied or released. /// The target slot. /// The subscription to store. public static void Assign(ref IDisposable? slot, IDisposable subscription) diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs index 62599da0..05968d3d 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs @@ -7,10 +7,9 @@ namespace ReactiveUI.Primitives.Advanced; /// -/// Observer that serializes notifications behind a gate so downstream operators always observe the -/// single-threaded OnNext* then OnError|OnCompleted grammar they depend on, even when the -/// upstream source delivers concurrently. Stateful sinks (counting, distinct, buffering) rely on that -/// grammar; placing one of these ahead of them is the supported way to consume a non-conformant source. +/// Observer that serializes notifications behind a gate, so downstream operators observe the single-threaded +/// OnNext* then OnError|OnCompleted grammar even when the upstream source delivers +/// concurrently. Place one ahead of a stateful sink to consume a source that does not honour that grammar. /// /// The value type. [System.Diagnostics.DebuggerDisplay("SynchronizeWitness: Observer = {_observer}, Subscription = {_subscription}")] @@ -68,7 +67,7 @@ public void OnCompleted() } } - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/TakeWhileWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/TakeWhileWitness.cs index 1b8f6bcb..62f3a26c 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/TakeWhileWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/TakeWhileWitness.cs @@ -65,7 +65,7 @@ public void OnError(Exception error) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => Complete(); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/TakeWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/TakeWitness.cs index 18405486..0fcf92cb 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/TakeWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/TakeWitness.cs @@ -26,7 +26,7 @@ public sealed class TakeWitness(IObserver observer, int count) : IObserver /// Non-zero after completion, error, or disposal. private int _stopped; - /// Sets the upstream subscription. + /// Assigns the upstream subscription so the sink can release it once the count is reached. /// Upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => _subscription.Create(subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/TapWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/TapWitness.cs index 66d95268..364c0a7c 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/TapWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/TapWitness.cs @@ -69,7 +69,7 @@ public void OnCompleted() } } - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/UnfoldSignal{TState,TResult}.cs b/src/ReactiveUI.Primitives.Core/Advanced/UnfoldSignal{TState,TResult}.cs index 396fad81..c1fa6b15 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/UnfoldSignal{TState,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/UnfoldSignal{TState,TResult}.cs @@ -7,13 +7,13 @@ namespace ReactiveUI.Primitives.Advanced; -/// Finite state expansion signal. -/// State type. -/// Result type. -/// Initial state. -/// Loop condition. -/// State iterator. -/// Result selector. +/// Signal that walks a state machine synchronously, emitting one projected value per iteration. +/// The state type. +/// The emitted value type. +/// The state the walk starts from. +/// Evaluated before each iteration; emission stops when it returns . +/// Produces the next state from the current one. +/// Projects the current state to the emitted value. [System.Diagnostics.DebuggerDisplay("UnfoldSignal: InitialState = {_initialState}, Condition = {_condition}")] public sealed class UnfoldSignal( TState initialState, @@ -21,16 +21,16 @@ public sealed class UnfoldSignal( Func iterate, Func resultSelector) : IRequireCurrentThread, IInlineSignal { - /// Initial state. + /// The state the walk starts from. private readonly TState _initialState = initialState; - /// Loop condition. + /// The continuation condition, evaluated before each iteration. private readonly Func _condition = condition; - /// State iterator. + /// The transition from the current state to the next. private readonly Func _iterate = iterate; - /// Result selector. + /// The projection from state to emitted value. private readonly Func _resultSelector = resultSelector; /// diff --git a/src/ReactiveUI.Primitives.Core/Advanced/UniqueByWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/UniqueByWitness.cs index 7182af77..03199651 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/UniqueByWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/UniqueByWitness.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Advanced; /// The key type. /// The downstream observer. /// The key projection. -/// The comparer used to compare adjacent keys. +/// The comparer applied to adjacent keys. [System.Diagnostics.DebuggerDisplay("UniqueByWitness: HasLast = {_hasLast}, Last = {_last}")] public sealed class UniqueByWitness( IObserver observer, @@ -24,7 +24,7 @@ public sealed class UniqueByWitness( /// The key projection. private readonly Func _keySelector = keySelector; - /// The comparer used to compare adjacent keys. + /// The comparer applied to adjacent keys. private readonly IEqualityComparer _comparer = comparer; /// A value indicating whether a previous key has been observed. @@ -66,7 +66,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/UniqueWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/UniqueWitness.cs index 63ef5653..464bf21f 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/UniqueWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/UniqueWitness.cs @@ -9,14 +9,14 @@ namespace ReactiveUI.Primitives.Advanced; /// Sink that suppresses adjacent duplicate values. /// The value type. /// The downstream observer. -/// The comparer used to compare adjacent values. +/// The comparer applied to adjacent values. [System.Diagnostics.DebuggerDisplay("UniqueWitness: HasLast = {_hasLast}, Last = {_last}")] public sealed class UniqueWitness(IObserver observer, IEqualityComparer comparer) : IObserver, IDisposable { /// The downstream observer. private readonly IObserver _observer = observer; - /// The comparer used to compare adjacent values. + /// The comparer applied to adjacent values. private readonly IEqualityComparer _comparer = comparer; /// A value indicating whether a previous value has been observed. @@ -57,7 +57,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => SinkTerminal.Complete(_observer, this); - /// Assigns the upstream subscription, disposing it if one is already held. + /// Assigns the upstream subscription, disposing the incoming one when this sink holds a subscription or has been disposed. /// The upstream subscription. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetSubscription(IDisposable subscription) => SinkSubscription.Set(ref _subscription, subscription); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs index 893e439c..f0b69257 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs @@ -7,21 +7,24 @@ namespace ReactiveUI.Primitives.Advanced; -/// Resource-scoped signal. -/// Resource type. -/// Value type. -/// Resource factory. -/// Signal factory. +/// +/// Signal that creates a resource for each subscription and disposes it once the inner signal terminates or +/// the subscription is disposed. A fault from either factory is delivered to the observer as an error. +/// +/// The resource type, disposed with the subscription. +/// The value type. +/// Creates the resource, once per subscription. +/// Creates the signal that consumes the resource. [System.Diagnostics.DebuggerDisplay("UseSignal: ResourceFactory = {_resourceFactory}, SignalFactory = {_signalFactory}")] public sealed class UseSignal( Func resourceFactory, Func> signalFactory) : IObservable where TResource : IDisposable { - /// Resource factory. + /// Creates the resource, once per subscription. private readonly Func _resourceFactory = resourceFactory; - /// Signal factory. + /// Creates the signal that consumes the resource. private readonly Func> _signalFactory = signalFactory; /// @@ -74,7 +77,7 @@ private sealed class UseWitness(IObserver observer, TResource resource) : IOb /// Non-zero once stopped. private int _stopped; - /// Assigns the inner subscription. + /// Stores the inner subscription, disposing it when the sink has stopped or holds one. /// Inner subscription. public void SetSubscription(IDisposable subscription) { @@ -166,7 +169,7 @@ private void Stop() Release(); } - /// Releases owned resources. + /// Detaches the observer, then disposes the inner subscription and the resource. private void Release() { _observer = EmptyWitness.Instance; diff --git a/src/ReactiveUI.Primitives.Core/Advanced/Witness.cs b/src/ReactiveUI.Primitives.Core/Advanced/Witness.cs index 067e310d..062cc56a 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/Witness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/Witness.cs @@ -49,7 +49,7 @@ public static IObserver Create(Action onNext, Action onCompleted) => /// Callback invoked for terminal errors. /// Callback invoked for completion. /// An observer backed by the supplied callbacks. - /// Any callback is . + /// Any callback is . public static IObserver Create(Action onNext, Action onError, Action onCompleted) { ArgumentExceptionHelper.ThrowIfNull(onNext); @@ -74,7 +74,7 @@ public static IObserver Safe(IObserver observer) => /// Observer to protect. /// Cancellation resource disposed on terminal signals or callback exceptions. /// A safe observer wrapper. - /// or is . + /// or is . public static IObserver Safe(IObserver observer, IDisposable cancel) { ArgumentExceptionHelper.ThrowIfNull(observer); diff --git a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs index 925d6f1a..df70de30 100644 --- a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs +++ b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// Provides the base implementation for a scheduled unit of work that is ordered by an absolute due time. /// -/// The type used to represent absolute time. The type must be comparable so scheduled items can be ordered. +/// The absolute-time representation, which must be comparable so scheduled items can be ordered. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ScheduledItem : IScheduledItem, IComparable>, @@ -30,11 +30,10 @@ public sealed class ScheduledItem : IScheduledItem, ICompa /// /// Initializes a new instance of the class with the due time, comparer, - /// and invocation callback. The callback supplies the work performed by and replaces what a - /// derived InvokeCore override used to provide. + /// and the callback that supplies the work performed by . /// /// The absolute time at which this item is due to run. - /// The comparer used to order due-time values. + /// The comparer that orders due-time values. /// /// The work performed when the item runs. It receives this scheduled item (so callers can deregister it) and /// returns a disposable that cancels any work that outlives the invocation. @@ -55,8 +54,8 @@ public ScheduledItem( /// Gets a value indicating whether this work item has been canceled or disposed. /// - /// Once this property becomes , calls to will not start new work. - /// If the work has already started and returned a disposable resource, that resource is disposed. + /// Once this is , starts no new work, and a disposable returned by + /// work that has begun is disposed as soon as it is available. /// public bool IsDisposed => Volatile.Read(ref _isDisposed) != 0; @@ -72,10 +71,6 @@ public ScheduledItem( /// when and do not refer to the same /// object; otherwise, . /// - /// - /// Equality operators use reference equality. They do not compare values and are therefore - /// intentionally different from the ordering behavior used by . - /// public static bool operator !=(ScheduledItem? left, ScheduledItem? right) => !ReferenceEquals(left, right); @@ -86,9 +81,6 @@ public ScheduledItem( /// when is ordered before by due time; /// otherwise, . /// - /// - /// This operator uses the same due-time ordering as . - /// public static bool operator <(ScheduledItem left, ScheduledItem right) => Comparer>.Default.Compare(left, right) < 0; @@ -99,9 +91,6 @@ public ScheduledItem( /// when is ordered before or equal to by /// due time; otherwise, . /// - /// - /// This operator uses the same due-time ordering as . - /// public static bool operator <=(ScheduledItem left, ScheduledItem right) => Comparer>.Default.Compare(left, right) <= 0; @@ -112,10 +101,6 @@ public ScheduledItem( /// when and refer to the same object; /// otherwise, . /// - /// - /// Equality operators use reference equality. They do not compare values and are therefore - /// intentionally different from the ordering behavior used by . - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator ==(ScheduledItem? left, ScheduledItem? right) => ReferenceEquals(left, right); @@ -127,9 +112,6 @@ public ScheduledItem( /// when is ordered after by due time; /// otherwise, . /// - /// - /// This operator uses the same due-time ordering as . - /// public static bool operator >(ScheduledItem left, ScheduledItem right) => Comparer>.Default.Compare(left, right) > 0; @@ -140,17 +122,13 @@ public ScheduledItem( /// when is ordered after or equal to by /// due time; otherwise, . /// - /// - /// This operator uses the same due-time ordering as . - /// public static bool operator >=(ScheduledItem left, ScheduledItem right) => Comparer>.Default.Compare(left, right) >= 0; - /// Cancels this scheduled work item. - /// - /// Canceling prevents work that has not started from running. If the work has already started and the invocation - /// callback returned a disposable resource, that resource is disposed as soon as it is available. - /// + /// + /// Cancels this scheduled work item: work that has not started does not run, and a disposable returned by work + /// that has begun is disposed as soon as it is available. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Cancel() => Dispose(); @@ -162,11 +140,10 @@ public ScheduledItem( /// greater than . /// /// - /// This comparison controls scheduling order. It does not imply object equality; and - /// the equality operators use reference identity. + /// Ordering is by due time and says nothing about equality: and the equality + /// operators compare reference identity instead. /// public int CompareTo(ScheduledItem? other) => - // MSDN: By definition, any object compares greater than null, and two null references compare equal to each other. other is null ? 1 : _comparer.Compare(DueTime, other.DueTime); /// Compares this scheduled item with another object by due time. @@ -210,10 +187,6 @@ public void Dispose() /// when is the same object instance; otherwise, /// . /// - /// - /// Equality is based on reference identity, not on . Use - /// to compare scheduling order. - /// public override bool Equals(object? obj) => ReferenceEquals(this, obj); /// Returns a hash code based on this scheduled item's object identity. @@ -222,9 +195,8 @@ public void Dispose() /// Runs this scheduled work item if it has not been canceled. /// - /// The implementation calls the invocation callback once and stores the disposable it returns. If cancellation - /// happens before, during, or immediately after invocation, the returned disposable is disposed to propagate - /// cancellation to any work created by the invocation. + /// The callback runs once and the disposable it returns is stored. Cancellation before, during, or immediately + /// after the call disposes that disposable, so cancellation reaches work the callback created. /// public void Invoke() { diff --git a/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs b/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs index 9add5054..b2c6b61f 100644 --- a/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs +++ b/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Concurrency; -/// Efficient scheduler queue that maintains scheduled items sorted by absolute time. +/// Scheduler queue that keeps scheduled items ordered by absolute due time. /// Absolute time representation type. /// This type is not thread safe; users should ensure proper synchronization. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -20,15 +20,15 @@ public class SequencerQueue /// Priority queue storing scheduled work. private readonly PriorityQueue> _queue; - /// Initializes a new instance of the class. Creates a new scheduler queue with a default initial capacity. + /// Initializes a new instance of the class with a default initial capacity. public SequencerQueue() : this(DefaultCapacity) { } - /// Initializes a new instance of the class. Creates a new scheduler queue with the specified initial capacity. + /// Initializes a new instance of the class with the specified initial capacity. /// Initial capacity of the scheduler queue. - /// is less than zero. + /// is less than zero. public SequencerQueue(int capacity) { ArgumentOutOfRangeExceptionHelper.ThrowIfNegative(capacity); diff --git a/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs b/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs index 447ba338..2dca23fb 100644 --- a/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs @@ -43,7 +43,7 @@ public ConnectableSignal(IObservable source, ISignal hub) [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Subscribes the hub to the source if it is not already connected. + /// Subscribes the hub to the source, returning the live handle when a connection is open. /// A handle that disconnects the source subscription. public IDisposable Connect() { @@ -54,9 +54,7 @@ public IDisposable Connect() return Scope.Empty; } - // Allocate the connection only on the first connect. A dedicated disposable type - // avoids the closure (and extra anonymous-disposable wrapper) that Scope.Create - // would allocate. + // A dedicated disposable type for the handle, so connecting captures no closure. if (_connection?.Value is { } activeConnection) { return activeConnection; diff --git a/src/ReactiveUI.Primitives.Core/ExceptionExtensions.cs b/src/ReactiveUI.Primitives.Core/ExceptionExtensions.cs index 96feb0af..ef15344f 100644 --- a/src/ReactiveUI.Primitives.Core/ExceptionExtensions.cs +++ b/src/ReactiveUI.Primitives.Core/ExceptionExtensions.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives; -/// Exception helper methods. +/// Extension methods for rethrowing exceptions. public static class ExceptionExtensions { /// Throwing operators for an exception. diff --git a/src/ReactiveUI.Primitives.Core/Optional.cs b/src/ReactiveUI.Primitives.Core/Optional.cs index a84cd639..18339261 100644 --- a/src/ReactiveUI.Primitives.Core/Optional.cs +++ b/src/ReactiveUI.Primitives.Core/Optional.cs @@ -7,22 +7,18 @@ namespace ReactiveUI.Primitives; -/// Represents an optional value that may or may not be present. +/// +/// Represents an optional value, letting an API distinguish an absent value from a default one without using +/// . A value is treated as absent. +/// /// The type of the value that may be contained by the optional. -/// Use this struct to indicate the presence or absence of a value without resorting to null references. -/// When an instance has a value, the HasValue property is and the Value property returns the -/// contained value. If no value is present, HasValue is and accessing Value throws an -/// exception. This pattern is useful for APIs that need to distinguish between an explicit 'no value' state and a -/// default value. [System.Diagnostics.DebuggerDisplay("Optional: HasValue = {HasValue}, Value = {_value}")] public readonly record struct Optional { /// The underlying value, or when no value is present. private readonly T? _value; - /// Initializes a new instance of the struct. - /// After using this constructor, the HasValue property is set to false, indicating that the - /// instance does not contain a value. + /// Initializes a new instance of the struct with no value. public Optional() => (_value, HasValue) = (default, false); /// Initializes a new instance of the struct. @@ -36,8 +32,6 @@ private Optional([AllowNull] T value, bool hasValue) => (_value, HasValue) = hasValue && value is not null ? (value, true) : (default, false); /// Gets an empty instance of the type that contains no value. - /// Use this property to represent the absence of a value in a type-safe manner. The returned - /// instance has no value set and IsPresent is false. public static Optional Empty => new(); /// Gets an empty optional value. @@ -47,9 +41,7 @@ private Optional([AllowNull] T value, bool hasValue) => public bool HasValue { get; } /// Gets the value contained in the optional object. - /// Accessing this property when the optional object does not have a value will throw an - /// exception. Use the HasValue property to determine whether a value is present before accessing this - /// property. + /// is . [NotNull] public T? Value => HasValue ? _value! @@ -65,12 +57,12 @@ private Optional([AllowNull] T value, bool hasValue) => /// The optional value. public static implicit operator Optional([AllowNull] T value) => ToOptional(value); - /// Explicit cast from option to value. - /// The value. - /// The optional value. + /// Explicit cast from the optional to the value. + /// The optional value. + /// The contained value. public static explicit operator T?(in Optional value) => FromOptional(value); - /// Creates the specified value. + /// Creates an optional value, treating a value as absent. /// The value. /// The optional value. public static Optional Create([AllowNull] T value) => new(value); diff --git a/src/ReactiveUI.Primitives.Core/Result.cs b/src/ReactiveUI.Primitives.Core/Result.cs index b6a6914f..9206c34d 100644 --- a/src/ReactiveUI.Primitives.Core/Result.cs +++ b/src/ReactiveUI.Primitives.Core/Result.cs @@ -7,18 +7,13 @@ namespace ReactiveUI.Primitives; -/// Represents the outcome of an operation, indicating success or failure and providing error details when applicable. -/// The struct is used to encapsulate the result of an operation, including whether -/// it succeeded and, if not, the exception that caused the failure. Use the property for -/// successful results and to create failed results. The and -/// properties allow callers to check the operation's status before accessing error information -/// or propagating exceptions. This struct is immutable and thread-safe. +/// Represents the outcome of an operation, indicating success or failure and carrying the failure exception. [System.Diagnostics.DebuggerDisplay("Result: IsSuccess = {IsSuccess}, Exception = {Exception}")] public readonly record struct Result { - /// Initializes a new instance of the struct. Initializes a new instance of the Result class with the specified exception. - /// The exception that represents the error condition for this result. Cannot be null. - /// Thrown if is null. + /// Initializes a new instance of the struct representing a failure. + /// The exception that caused the failure. + /// is . public Result(Exception exception) { ArgumentExceptionHelper.ThrowIfNull(exception); @@ -33,24 +28,22 @@ public Result(Exception exception) public Exception? Exception { get; } /// Gets a value indicating whether the operation completed successfully without an exception. - /// If , the Exception property is guaranteed to be non-null, - /// providing details about the failure. + /// When , is non-null. [MemberNotNullWhen(false, nameof(Exception))] public bool IsSuccess => Exception is null; /// Gets a value indicating whether the operation has failed. - /// When , the Exception property is guaranteed to be non-null. Use - /// this property to check for failure before accessing error details. + /// When , is non-null. [MemberNotNullWhen(true, nameof(Exception))] public bool IsFailure => Exception is not null; /// Creates a failed result that encapsulates the specified exception. - /// The exception that describes the failure. Cannot be null. - /// A result representing a failure, containing the provided exception. + /// The exception that describes the failure. + /// A result representing a failure, containing the supplied exception. + /// is . public static Result Failure(Exception exception) => new(exception); - /// Throws the associated exception if the result represents a failure. - /// Excluded from coverage: the unreachable sequence point after rethrow cannot be credited by cobertura. + /// Rethrows the failure exception, preserving its original stack trace; a no-op on success. [ExcludeFromCodeCoverage] public void TryThrow() { @@ -66,7 +59,5 @@ public void TryThrow() /// Returns a string that represents the result status of the operation. /// A string indicating "Success" if the operation was successful; otherwise, a string in the format /// "Failure{exception message}" containing the associated exception message. - /// This method provides a concise textual representation of the operation's outcome, which can - /// be useful for logging or debugging purposes. public override string ToString() => IsSuccess ? "Success" : $"Failure{{{Exception.Message}}}"; } diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs index 7dea08ce..79c5db64 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs @@ -26,8 +26,8 @@ internal static class ActivationExtensionsEmitter /// The overloads to emit, in request order. /// The generated source. /// - /// The overloads join the same partial class as the placeholder they displace, so a call site resolves to the - /// concrete overload without the consumer importing anything new: a non-generic candidate beats the generic + /// The overloads join the same partial class as the placeholder they displace, so a call site binds to the + /// concrete overload without the consumer importing anything: a non-generic candidate beats the generic /// placeholder outright. /// internal static string Emit(ImmutableArray models) diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs index e25c691c..fa50f9e4 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs @@ -4,23 +4,17 @@ namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration; -/// The API a consumer writes against, injected before anything is scanned. +/// The API a consumer writes against, emitted into every compilation. /// -/// /// This is what makes the generator opt-in without a package-level runtime dependency: the placeholder -/// Events<T> gives a call site something to bind to while it is being typed, and the generated -/// overload for the receiver's own type displaces it once the type resolves. The attribute has to exist here too, -/// because a static host has no receiver to hang a call off. -/// -/// -/// Nothing here is annotated, and it deliberately carries no #nullable directive. Post-initialization -/// output is produced before anything about the consumer is known, including the language version, so this is the -/// one generated file that cannot ask whether the consumer could compile an annotation - and it has no reason to. -/// +/// Events<T> gives a call site something to bind to while it is being typed, and the generated overload +/// for the receiver's own type displaces it once the type resolves. The attribute has to exist here too because a +/// static host has no receiver to hang a call off, and nothing here is annotated, so this is the one generated file +/// that carries no #nullable directive. /// internal static class ActivationSource { - /// The source injected during post-initialization. + /// The source text added to every compilation. internal const string Text = """ // namespace ReactiveUI.Primitives.ObservableEvents diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs index 9987ec8d..056298b1 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs @@ -9,10 +9,10 @@ namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration; /// Builds the wrapper class and generated file names, from the identity of the host they belong to. /// -/// A readable name alone is not enough to key generated output on. Sanitizing punctuation out of a fully qualified -/// name maps distinct hosts onto the same identifier - Samples.A_B.C and Samples.A.B_C both flatten to -/// Samples_A_B_C - so a hash of the unflattened identity is appended to keep them apart, while the readable -/// half is kept so a generated file is still recognisable in a build log. +/// Sanitizing punctuation out of a fully qualified name maps distinct hosts onto the same identifier - +/// Samples.A_B.C and Samples.A.B_C both flatten to Samples_A_B_C - so a hash of the unflattened +/// identity is appended to keep them apart, while the readable half keeps a generated file recognisable in a build +/// log. /// internal static class GeneratedNames { @@ -77,8 +77,8 @@ private static void AppendSanitized(PooledStringBuilder builder, string identity var start = 0; var end = identity.Length; - // Leading and trailing punctuation would sanitize to underscores that carry no information, and a - // generated name reads better without them; the hash still separates identities that differ only there. + // Leading and trailing punctuation would sanitize to uninformative underscores; the hash still separates + // identities that differ only there. while (start < end && !char.IsLetterOrDigit(identity[start])) { start++; diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/InstanceWrapperEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/InstanceWrapperEmitter.cs index 698d9d1a..7af6fb34 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/InstanceWrapperEmitter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/InstanceWrapperEmitter.cs @@ -19,10 +19,6 @@ internal static class InstanceWrapperEmitter /// The host to wrap. /// The observable implementation to write against. /// The generated source. - /// - /// The host is held in a field rather than resubscribed from a captured expression, so every property on one - /// wrapper observes the same instance the consumer handed it. - /// internal static string Emit(InstanceTargetModel model, ObservableProvider provider) { var events = model.Events.AsArray(); diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs index 4338bcdd..d5e9ceb3 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs @@ -8,17 +8,9 @@ namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration; /// A fluent builder for generated source, backed by thread-local pooled character buffers. /// -/// -/// Emission builds a great many short fragments - a payload type here, a handler parameter list there - and one -/// large file per target. Accumulating into a pooled char[] lets the same buffers carry every fragment and -/// every file in a pass, so the steady state is a handful of arrays rather than a builder and its grown chunk -/// chain per fragment. -/// -/// /// The free list is thread-local rather than a shared pool: source-output callbacks run concurrently, fragment -/// builders nest inside file builders, and nothing here outlives the call that rented it. Returning is what buys -/// the reuse; forgetting to costs reuse, never correctness. -/// +/// builders nest inside file builders, and nothing here outlives the call that rented it. Returning a buffer is what +/// buys the reuse; forgetting to costs reuse, never correctness. /// internal sealed class PooledStringBuilder { @@ -97,8 +89,8 @@ internal PooledStringBuilder Append(char value) /// The value to append; the only callers pass a name length, so it is never negative. /// This builder, for chaining. /// - /// Formats digits straight into the buffer. These appends sit in the per-event loop that builds the mangled - /// static property names, where going through ToString would allocate a string per name segment. + /// Formats digits straight into the buffer; these appends sit in the per-event loop that builds the mangled + /// static property names, where ToString would allocate a string per name segment. /// internal PooledStringBuilder Append(int value) { @@ -122,7 +114,6 @@ internal PooledStringBuilder Append(int value) /// Appends another builder's content, then returns that builder's buffer to the pool. /// The fragment builder to drain; it must not be appended to afterwards. /// This builder, for chaining. - /// Copies buffer to buffer, so a nested fragment joins its file without materializing a string. internal PooledStringBuilder Append(PooledStringBuilder other) { if (other._position != 0) diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs index 66e27960..58448aef 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs @@ -57,9 +57,8 @@ internal static void AppendFooter(PooledStringBuilder builder, string namespaceN /// The observable implementation to write against. /// The indentation the property sits at. /// - /// The handler is a local function rather than a lambda so a delegate returning Task or - /// ValueTask can satisfy its own signature; the subscription is torn down through the provider's own - /// disposable factory, so the wrapper never holds the handler alive past the subscription. + /// The handler is emitted as a local function rather than a lambda so a delegate returning Task or + /// ValueTask can satisfy its own signature. /// internal static void AppendEventProperty( PooledStringBuilder builder, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs index 07f09c69..db30e7cc 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs @@ -21,8 +21,7 @@ internal static class StaticEventsEmitter /// The generated source. /// /// A static event has no instance to hang an extension method off, so it is reached through a class named the - /// same in every namespace. The class is partial, so a consumer can add to it and so that two requested hosts - /// in one namespace do not fight over the declaration. + /// same in every namespace, left partial so a consumer can add to it. /// internal static string Emit(StaticNamespaceModel model, ObservableProvider provider) { diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Constants.cs b/src/ReactiveUI.Primitives.ObservableEvents/Constants.cs index 99d23f09..4c32064b 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Constants.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Constants.cs @@ -20,7 +20,7 @@ internal static class Constants /// The same attribute written with the suffix the language lets a user leave off. internal const string StaticRequestAttributeQualifiedName = "GenerateStaticEventObservablesAttribute"; - /// The hint name of the activation API emitted during post-initialization. + /// The hint name of the file carrying the activation API. internal const string ActivationHintName = "ObservableEvents.Activation.g.cs"; /// The hint name of the file carrying every generated activation overload. @@ -39,11 +39,6 @@ internal static class Constants internal const string GeneratedFileHeader = "// \n"; /// The directive that puts a generated file in a nullable-aware context. - /// - /// Emitted only where the consumer's language version allows it, and always together with the annotations on - /// the generated handler signatures: the directive is what makes those annotations mean anything, and the - /// annotations are what make the handlers match the delegates they are assigned to. - /// internal const string NullableEnableDirective = "#nullable enable\n"; /// The lean signal factory host, whose presence selects the lean provider. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs index 6f0bfec7..59211884 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs @@ -13,17 +13,11 @@ namespace ReactiveUI.Primitives.ObservableEvents; /// Generates observable wrappers for the event-bearing types a consumer asks for. /// -/// -/// Two things ask for generation. An Events() call names its receiver, which is what makes the API -/// discoverable from the call site; an assembly attribute names a static host, which has no receiver to call -/// through. Both converge on the same extraction and the same emitter. -/// -/// -/// Everything that leaves a semantic transform is a model of strings that compares by value, and every output is -/// keyed on the smallest model that decides it: one wrapper per host, one file per namespace of static events, one -/// file of activation overloads. An edit to one host's events therefore re-emits that host's file and nothing else, -/// and an edit anywhere else re-emits nothing at all. -/// +/// Generation is requested either by an Events() call, whose receiver names the host, or by an assembly +/// attribute naming a static host; both converge on the same extraction and the same emitters. Everything leaving a +/// semantic transform is a model of strings that compares by value, and every output is keyed on the smallest model +/// that decides it - one wrapper per host, one file per namespace of static events, one file of activation +/// overloads - so an edit to one host's events re-emits that host's file alone. /// [Generator(LanguageNames.CSharp)] public sealed class EventGenerator : IIncrementalGenerator @@ -33,9 +27,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { RegisterActivationOutput(in context); - // Which observable library is referenced decides every type name in the generated source, but nothing about - // which events exist. Resolving it here, into a value the pipeline can compare, keeps the far more - // expensive extraction from re-running when only the reference set moves. + // Which observable library is referenced decides every type name in the generated source but nothing about + // which events exist, so resolving it separately keeps extraction from re-running when references move. var provider = context.CompilationProvider .Select(static (compilation, _) => ProviderResolver.Resolve(compilation)) .WithTrackingName(GeneratorStepNames.Provider); @@ -67,12 +60,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) /// Registers the activation API a consumer writes against. /// The generator initialization context. /// - /// Deliberately an ordinary source output rather than post-initialization output, even though it depends on - /// nothing and could be produced before anything is scanned. Post-initialization source is added to the - /// compilation the pipeline then runs against, which makes that compilation new on every single run and throws - /// away every semantic result cached against the previous one - so a driver that has already generated - /// re-binds every call site from scratch, whether or not anything changed. One inert post-initialization file - /// is enough to cost that, so this generator emits none. + /// An ordinary source output rather than post-initialization output: post-initialization source is added to the + /// compilation the pipeline then runs against, making that compilation new on every run and discarding every + /// semantic result cached against the one it replaces. One inert post-initialization file costs that, so this + /// generator registers none. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void RegisterActivationOutput(in IncrementalGeneratorInitializationContext context) => diff --git a/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs index 8cedc9b1..d2f22bd4 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs @@ -6,10 +6,8 @@ namespace ReactiveUI.Primitives.ObservableEvents; /// The names the pipeline's steps are tracked under. /// -/// Tracking is what makes the caching testable: a driver told to track steps records, for every run, whether each -/// step recomputed its value and whether that value differed from last time. Without names on the steps a -/// regression that quietly reintroduces a symbol into a model - and so defeats the caching entirely - still -/// produces correct output and would go unnoticed. +/// A driver told to track steps reports, per run, whether each named step recomputed and whether its value changed, +/// which is how a test asserts that the models compare by value and the caching holds. /// internal static class GeneratorStepNames { diff --git a/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs b/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs index 3d09f98d..f528e8cb 100644 --- a/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs +++ b/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs @@ -125,7 +125,7 @@ public override IDisposable Schedule( // The timer roots itself while armed through the callback's target (the work item), which stores the // timer; the item's Dispose cancels and releases it. - item.AttachTimer(new(static s => ((IReadyWorkItem)s!).Run(), item, dt, Timeout.InfiniteTimeSpan)); + item.AttachTimer(new Timer(static s => ((IReadyWorkItem)s!).Run(), item, dt, Timeout.InfiniteTimeSpan)); return item; } @@ -181,8 +181,7 @@ public void Dispose() /// Work item to execute on the next event-loop turn. internal void Enqueue(IReadyWorkItem item) { - _ready.Enqueue(item); - _ = Interlocked.Increment(ref _readyCount); + QueueReady(item); PostDrain(); // A disposal that raced the enqueue above may have drained the queue before this item joined it. Re-check @@ -196,6 +195,32 @@ internal void Enqueue(IReadyWorkItem item) ReleaseReady(); } + /// + /// Adds a work item to the ready queue without arming a drain. Internal rather than private so a test can order an + /// enqueue, a disposal and a drain as explicit steps instead of racing them across threads. + /// + /// Work item to add to the ready queue. + internal void QueueReady(IReadyWorkItem item) + { + _ready.Enqueue(item); + _ = Interlocked.Increment(ref _readyCount); + } + + /// + /// Runs one batch: every item the ready count promised, stopping early if a concurrent drain took one first. + /// Internal rather than private so a test can run a drain pass at the point in a sequence it chooses. + /// + internal void RunReadyBatch() + { + for (var remaining = Volatile.Read(ref _readyCount); + remaining > 0 && _ready.TryDequeue(out var item); + remaining--) + { + _ = Interlocked.Decrement(ref _readyCount); + item.Run(); + } + } + /// /// Cancels and drops every ready item. The items are the handles their callers hold, so disposing them releases /// the caller's work instead of stranding it in a queue nothing will ever drain again. @@ -291,18 +316,6 @@ private void RunDrain() PostDrain(); } - /// Runs one batch: every item the ready count promised, stopping early if a concurrent drain took one first. - private void RunReadyBatch() - { - for (var remaining = Volatile.Read(ref _readyCount); - remaining > 0 && _ready.TryDequeue(out var item); - remaining--) - { - _ = Interlocked.Decrement(ref _readyCount); - item.Run(); - } - } - /// /// A cancellable scheduled work item carrying closure-free state and the scheduler passed back to the action; /// also the target that roots a delayed one-shot timer. The run/cancel handshake lives in the shared @@ -312,8 +325,12 @@ private void RunReadyBatch() /// The scheduled state type. internal sealed class StatefulWorkItem : DispatchWorkItemBase, IReadyWorkItem { - /// Timer driving a delayed item; for immediate work. - private Timer? _timer; + /// + /// Release handle for the one-shot timer driving a delayed item; for immediate work. + /// Typed as the cancellation the item actually performs rather than the timer itself, so a test can hand it a + /// recording handle and observe the release directly. + /// + private IDisposable? _timer; /// Initializes a new instance of the class. /// The scheduler passed back to the scheduled action. @@ -340,7 +357,7 @@ public void Dispose() /// Stores the one-shot timer so the caller's disposable cancels and releases it. /// The armed timer. - internal void AttachTimer(Timer timer) + internal void AttachTimer(IDisposable timer) { Volatile.Write(ref _timer, timer); if (!IsDisposed) diff --git a/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs b/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs index afec21ba..1493e25b 100644 --- a/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs +++ b/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs @@ -10,9 +10,9 @@ namespace ReactiveUI.Primitives.Advanced; /// -/// Coalescing engine shared by UI-thread sequencers: it batches dispatcher posts and shares delayed scheduling. -/// A sealed sequencer holds one inline and injects its platform post (and optionally scheduleDelayed) -/// delegates plus the cached drain callback; immediate work is queued and drained one batch per post. +/// Coalescing engine shared by UI-thread sequencers. A sequencer holds one inline and supplies its platform +/// post delegate, an optional scheduleDelayed delegate and a cached drain callback; immediate work is +/// queued and drained one batch per post. /// [SuppressMessage( "Performance", @@ -82,7 +82,7 @@ public DispatchSequencerState( [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TimeSpan DelayUntil(long dueTimestamp) => Sequencer.TimeUntil(dueTimestamp); - /// Executes the work item on the current (dispatcher) thread unless it has already been cancelled. + /// Executes the work item on the current (dispatcher) thread unless it has been cancelled. /// The work item to execute. public static void RunIfActive(IWorkItem item) { @@ -188,15 +188,10 @@ public void RunDrain() } } - /// - /// Cancels and drops every ready work item. The items are the handles their callers hold, so disposing them - /// releases the caller's work instead of stranding it in a queue nothing will ever drain again. - /// + /// Cancels and drops every ready work item, disposing each so its caller's handle is released. /// - /// Internal rather than public because only a sequencer that can retire its own dispatcher needs it: the platform - /// dispatchers (WPF, WinForms, WinUI, MAUI, Blazor) outlive the sequencer that posts to them and keep draining, so - /// they never release a queue. owns the timer that is its dispatcher, and once that is - /// disposed nothing can drain the queue again — so it, alone, hands the queued work back. + /// Only calls this: it owns the timer that is its dispatcher, so disposal leaves + /// nothing able to drain the queue, whereas the platform dispatchers outlive the sequencer and keep draining. /// internal void ReleaseQueued() { @@ -210,8 +205,7 @@ internal void ReleaseQueued() } } - // The only trigger for this path is the real shared thread-pool timer coming due, so no deterministic test can - // reach it without waiting on a live OS timer; that timer race is exactly what flaked, so exclude it from coverage. + // Only the live shared thread-pool timer coming due reaches this path, so coverage excludes it. /// Parks delayed work on the shared thread-pool timer, which marshals it back to the dispatcher when due. /// Work item to execute once due. /// Absolute monotonic timestamp at which to execute the item. @@ -220,8 +214,7 @@ internal void ReleaseQueued() private readonly void ScheduleOnSharedTimer(IWorkItem item, long dueTimestamp) => ThreadPoolSequencer.Instance.Schedule(new MarshalOnDueWorkItem(_owner, item), dueTimestamp); - // Constructed only by ScheduleOnSharedTimer and run only by the shared thread-pool timer, so it shares that - // path's lack of a deterministic trigger; exclude it from coverage. + // Constructed and run only by the shared thread-pool timer path, so coverage excludes it too. /// Work item used by the shared timer path to marshal delayed work back to the dispatcher. /// Owning dispatch sequencer. /// Work item to marshal. diff --git a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs index 1d37ecd6..e5b8b8fa 100644 --- a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs @@ -36,7 +36,7 @@ private CurrentThreadSequencer() /// Gets the singleton instance of the current thread scheduler. public static CurrentThreadSequencer Instance => StaticInstance.Value; - /// Gets a value indicating whether gets a value that indicates whether the caller must call a Schedule method. + /// Gets a value indicating whether the caller must schedule work instead of running it inline, true when the current thread is outside any scheduled call. [EditorBrowsable(EditorBrowsableState.Advanced)] public static bool IsScheduleRequired => !_running; @@ -53,7 +53,7 @@ private CurrentThreadSequencer() /// Schedules an action to be executed on the current-thread trampoline. /// Action to execute. - /// The disposable object used to cancel queued work, or an empty disposable when the action has already run. + /// The disposable object used to cancel queued work, or an empty disposable when the action ran inline. /// is . public IDisposable Schedule(Action action) { @@ -105,7 +105,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) SequencerQueue? queue; - // There is no timed task and no task is currently running + // Nothing is running on this thread, so the item runs inline rather than through the trampoline. if (!_running) { SetRunning(true); @@ -116,7 +116,6 @@ public void Schedule(IWorkItem item, long dueTimestamp) Thread.Sleep(dueTime); } - // execute directly without queueing try { if (!Sequencer.IsCancelled(item)) @@ -131,10 +130,8 @@ public void Schedule(IWorkItem item, long dueTimestamp) throw; } - // did recursive tasks arrive? + // Work the item scheduled recursively runs on the same trampoline before returning. queue = GetQueue(); - - // yes, run those in the queue as well if (queue is not null) { try @@ -157,14 +154,13 @@ public void Schedule(IWorkItem item, long dueTimestamp) queue = GetQueue(); - // if there is a task running or there is a queue + // Work is running on this thread, so the item joins the trampoline queue, created on first use. if (queue is null) { queue = new(InitialQueueCapacity); SetQueue(queue); } - // queue up more work ScheduledItem si = new(dueTimestamp, Comparer.Default, _ => { if (!Sequencer.IsCancelled(item)) diff --git a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs index 87df50ba..f17d94db 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs @@ -35,7 +35,7 @@ private ImmediateSequencer() /// Schedules an action to run immediately. /// Action to execute. - /// An empty disposable because the action has already run. + /// An empty disposable; the action runs before this method returns. /// is . public IDisposable Schedule(Action action) { diff --git a/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs b/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs index ee9d2696..d0bc349b 100644 --- a/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs +++ b/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs @@ -144,14 +144,7 @@ public void Execute() DisposeIfRaced(disposable); } - /// - /// Race-only cleanup: releases what the action returned when latched the - /// flag after the store above claimed the slot. Single-threaded this can never fire - a completed - /// leaves the slot holding , so the - /// compare-exchange takes the already-claimed branch instead and never reaches here. Only a real - /// concurrent disposal lands in this window, so it is excluded rather than chased with a - /// timing-dependent test. - /// + /// Releases what the scheduled action returned when a concurrent latches the cancellation flag after the slot is claimed. /// The disposable the scheduled action returned. [ExcludeFromCodeCoverage] private void DisposeIfRaced(IDisposable disposable) @@ -250,7 +243,7 @@ private sealed class RescheduleHandoff /// Gets or sets a value indicating whether the disposable was added to the collection. public bool IsAdded { get; set; } - /// Gets or sets a value indicating whether the rescheduled work item already ran. + /// Gets or sets a value indicating whether the rescheduled work item has run. public bool IsDone { get; set; } } } diff --git a/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs b/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs index 90c7c608..fd187d0c 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs @@ -149,14 +149,7 @@ public IDisposable Schedule(Action action) /// Schedules an action to be executed after the specified relative due time. /// Relative time after which to execute the action. /// Action to execute. - /// - /// The disposable object used to cancel the scheduled action (best effort). - /// - /// - /// scheduler - /// or - /// action. - /// + /// The disposable object used to cancel the scheduled action (best effort). /// or is null. public IDisposable Schedule(TimeSpan dueTime, Action action) { @@ -170,14 +163,7 @@ public IDisposable Schedule(TimeSpan dueTime, Action action) /// Schedules an action to be executed at the specified absolute due time. /// Absolute time at which to execute the action. /// Action to execute. - /// - /// The disposable object used to cancel the scheduled action (best effort). - /// - /// - /// scheduler - /// or - /// action. - /// + /// The disposable object used to cancel the scheduled action (best effort). /// or is null. public IDisposable Schedule(DateTimeOffset dueTime, Action action) { @@ -188,9 +174,9 @@ public IDisposable Schedule(DateTimeOffset dueTime, Action action) return scheduler.Schedule(action, dueTime, static (_, a) => Sequencer.Invoke(a)); } - /// Schedules the specified action. - /// The action. - /// The disposable object used to cancel the scheduled action (best effort). + /// Schedules a recursive action, which receives a delegate that schedules its next invocation. + /// Action to execute; its argument schedules the next iteration. + /// The disposable object used to cancel the recursive work (best effort). public IDisposable Schedule(Action action) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -206,7 +192,7 @@ public IDisposable Schedule(Action action) /// Action to execute. /// The disposable object used to cancel the scheduled action (best effort). /// or is null. - /// The naming of this method differs from Schedule because otherwise the signature would cause ambiguities. + /// Named ScheduleAction because a Schedule overload taking state plus an action would be ambiguous with the others. public IDisposable ScheduleAction(TState state, Action action) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -228,7 +214,6 @@ public IDisposable ScheduleAction(TState state, Action action) /// Action to execute. /// The disposable object used to cancel the scheduled action (best effort). /// or is null. - /// The naming of this method differs from Schedule because otherwise the signature would cause ambiguities. internal IDisposable ScheduleAction(TState state, Func action) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -245,14 +230,7 @@ internal IDisposable ScheduleAction(TState state, FuncA state object to be passed to . /// Relative time after which to execute the action. /// Action to execute. - /// - /// The disposable object used to cancel the scheduled action (best effort). - /// - /// - /// scheduler - /// or - /// action. - /// + /// The disposable object used to cancel the scheduled action (best effort). /// or is null. internal IDisposable ScheduleAction(TState state, TimeSpan dueTime, Action action) { @@ -268,14 +246,7 @@ internal IDisposable ScheduleAction(TState state, TimeSpan dueTime, Acti /// A state object to be passed to . /// Relative time after which to execute the action. /// Action to execute. - /// - /// The disposable object used to cancel the scheduled action (best effort). - /// - /// - /// scheduler - /// or - /// action. - /// + /// The disposable object used to cancel the scheduled action (best effort). /// or is null. internal IDisposable ScheduleAction(TState state, TimeSpan dueTime, Func action) { @@ -291,14 +262,7 @@ internal IDisposable ScheduleAction(TState state, TimeSpan dueTime, Func /// A state object to be passed to . /// Relative time after which to execute the action. /// Action to execute. - /// - /// The disposable object used to cancel the scheduled action (best effort). - /// - /// - /// scheduler - /// or - /// action. - /// + /// The disposable object used to cancel the scheduled action (best effort). /// or is null. internal IDisposable ScheduleAction(TState state, DateTimeOffset dueTime, Action action) { @@ -314,14 +278,7 @@ internal IDisposable ScheduleAction(TState state, DateTimeOffset dueTime /// A state object to be passed to . /// Relative time after which to execute the action. /// Action to execute. - /// - /// The disposable object used to cancel the scheduled action (best effort). - /// - /// - /// scheduler - /// or - /// action. - /// + /// The disposable object used to cancel the scheduled action (best effort). /// or is null. internal IDisposable ScheduleAction( TState state, diff --git a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs index 12a06f08..cc5161d5 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs @@ -17,7 +17,6 @@ public SynchronizationContextSequencer(SynchronizationContext context) => /// Gets a sequencer for the current synchronization context. /// There is no current synchronization context. - /// Coverage excludes the getter because the ambient context cannot be changed safely by parallel tests. public static SynchronizationContextSequencer Current { [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] @@ -61,7 +60,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) ThreadPoolSequencer.Instance.Schedule(new DelayedPostWorkItem(this, item), dueTimestamp); } - /// Executes work when it has not already been cancelled. + /// Executes the work item unless it has been cancelled. /// Work item to execute. private static void ExecutePosted(IWorkItem item) { diff --git a/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs b/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs index 2bb0438f..c67d9449 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs @@ -5,9 +5,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// -/// Thread-safe wrapper around used by the virtual-time sequencers. Bundling -/// the queue with its lock lets the self-removal callback synchronize through a reference without capturing the -/// owning struct's this. +/// Thread-safe wrapper around that pairs the queue with its lock, so the +/// virtual-time self-removal callback synchronizes through one reference instead of capturing the owning struct. /// /// Absolute time representation type. internal sealed class SynchronizedSequencerQueue diff --git a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs index a35446ff..cd26e04d 100644 --- a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs @@ -21,10 +21,7 @@ public sealed class TaskPoolSequencer : ISequencer public TaskPoolSequencer(TaskFactory taskFactory) => _taskFactory = taskFactory ?? throw new ArgumentNullException(nameof(taskFactory)); - /// Gets the instance. - /// - /// The instance. - /// + /// Gets the shared sequencer backed by . public static TaskPoolSequencer Instance { get; } = new(Task.Factory); /// Gets the default task-pool scheduler. diff --git a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs index b09e7490..defeae76 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs @@ -29,17 +29,12 @@ public sealed class ThreadPoolSequencer : ISequencer, IDisposable private readonly Timer _timer; /// - /// Non-zero once has released the timer and the queue. Written under - /// so every path that touches the timer is ordered against disposal, but read without it on the immediate path, - /// which never goes near the timer. + /// Non-zero once has released the timer and the queue; written under so + /// timer paths are ordered against disposal, and read unlocked on the immediate path, which ignores the timer. /// private int _isDisposed; - /// - /// Initializes a new instance of the class. Callers use ; - /// this is internal so a test can own an isolated sequencer it may dispose without shutting the shared singleton - /// down for every other test. - /// + /// Initializes a new instance of the class; callers use . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Correctness", "SST2403:Do not let 'this' escape from a constructor", @@ -95,8 +90,8 @@ public void Schedule(IWorkItem item, long dueTimestamp) lock (_gate) { - // Tested under the same gate disposal takes, so an item that makes it into the queue is one disposal is - // guaranteed to see and release. It can never be enqueued behind an already-released timer. + // Checked under the gate disposal takes, so an item that reaches the queue is one disposal will see and + // release; it can never land behind a released timer. ObjectDisposedExceptionHelper.ThrowIf(IsDisposed, this); _queue.Enqueue(new(item, dueTimestamp)); @@ -105,15 +100,15 @@ public void Schedule(IWorkItem item, long dueTimestamp) } /// - /// Releases the delay timer this sequencer owns and cancels the delayed work still queued behind it. Scheduling - /// through a disposed sequencer throws rather than accepting work that - /// could never become due. Work the thread pool has already picked up runs to completion. + /// Releases the delay timer this sequencer owns and cancels the delayed work queued behind it. Scheduling through + /// a disposed sequencer throws rather than accepting work that could never + /// become due. Work the thread pool has picked up runs to completion. /// public void Dispose() { - // Under the gate: every arm of the timer happens under it too, so the timer can never be re-armed after it - // is released here. Timer.Dispose does not wait for an in-flight callback, so a drain blocked on the gate - // cannot deadlock this — it simply observes the disposed flag once it gets in. + // Arming the timer takes this gate too, so it cannot be re-armed after the release below. Timer.Dispose does + // not wait for an in-flight callback, so a drain blocked on the gate observes the disposed flag rather than + // deadlocking here. lock (_gate) { if (IsDisposed) @@ -127,7 +122,7 @@ public void Dispose() } } - /// Executes a work item when it has not already been cancelled. + /// Executes the work item unless it has been cancelled. /// Work item to execute. private static void ExecuteQueued(IWorkItem item) { @@ -186,10 +181,7 @@ private bool TryDequeueDueNoLock(out TimedWorkItem item) return false; } - /// - /// Cancels and drops every queued delayed item. The items are the handles their callers hold, so disposing them - /// releases the caller's work instead of stranding it in a queue nothing will ever drain again. - /// + /// Cancels and drops every queued delayed item, disposing each so its caller's handle is released. private void ReleaseQueuedNoLock() { while (_queue.TryDequeue(out var pending)) @@ -206,8 +198,8 @@ private void ArmTimerNoLock() { if (IsDisposed) { - // Disposal released the timer and the queue under this same gate. A drain that is still unwinding on - // the timer's callback thread lands here, and must not re-arm a timer that no longer exists. + // Disposal released the timer and the queue under this same gate, so a drain unwinding on the timer's + // callback thread lands here and must not re-arm a released timer. return; } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs index b6837bc6..4cc6cfab 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs @@ -389,21 +389,19 @@ public async Task ShiftAndExpireAliasesUseTimeBasedOperators() /// The completed task result. private static async Task DrainUntilComplete(Task task, QueuedSequencer sequencer) { - const int MaxIterations = 1_000; - const int PollDelayMilliseconds = 1; - const int TimeoutSeconds = 5; - for (var i = 0; i < MaxIterations; i++) + while (true) { + // Capture the arrival signal before draining so work queued during the drain is not missed. + var queued = sequencer.WorkArrived; sequencer.DrainAll(); + if (task.IsCompleted) { return await task.ConfigureAwait(false); } - await Task.Delay(PollDelayMilliseconds).ConfigureAwait(false); + _ = await Task.WhenAny(task, queued).ConfigureAwait(false); } - - return await task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)).ConfigureAwait(false); } /// Reads a short async enumerable sequence for factory alias coverage. @@ -527,6 +525,9 @@ private sealed class QueuedSequencer : ISequencer /// The queue of scheduled work items awaiting drain. private readonly ConcurrentQueue _items = new(); + /// Signals the arrival of a work item; replaced with a fresh source on every arrival. + private TaskCompletionSource _arrival = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// public DateTimeOffset Now => FixedNow; @@ -536,11 +537,17 @@ private sealed class QueuedSequencer : ISequencer /// Gets the number of scheduled work items. public int ScheduleCount { get; private set; } + /// Gets a task that completes when the next work item is scheduled. + public Task WorkArrived => Volatile.Read(ref _arrival).Task; + /// public void Schedule(IWorkItem item) { ScheduleCount++; _items.Enqueue(item); + IgnoredResult.Of( + Interlocked.Exchange(ref _arrival, new(TaskCreationOptions.RunContinuationsAsynchronously)) + .TrySetResult()); } /// diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs index 66c7faf8..1406e851 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs @@ -28,7 +28,7 @@ public async Task AsyncContextRenamedMembersExposeDefaultAndSequencerSchedulerPa await Assert.That(AsyncContext.Default.UsesDefaultSequencer).IsTrue(); await Assert.That(sequencerContext.UsesDefaultSequencer).IsFalse(); await Assert.That(AsyncContext.From(new SynchronizationContext()).UsesDefaultSequencer).IsFalse(); - await Assert.That(AsyncContext.From(NewThreadTaskScheduler.Instance).UsesDefaultSequencer).IsFalse(); + await Assert.That(AsyncContext.From(CustomTaskScheduler.Instance).UsesDefaultSequencer).IsFalse(); await Assert.That(syncSequencerContext.SynchronizationContext).IsSameReferenceAs(syncSequencer); await Assert.That(sequencerContext.IsSameAsCurrentAsyncContext()).IsFalse(); await Assert.That(scheduler.Sequencer).IsSameReferenceAs(sequencer); @@ -67,7 +67,7 @@ public async Task AsyncContextSwitcherBranchesCoverCustomSchedulersAndCancellati canceledAwaitable.OnCompleted(() => cancellationCallbacks++); await Assert.That(cancellationCallbacks).IsEqualTo(1); TaskCompletionSource scheduled = new(TaskCreationOptions.RunContinuationsAsynchronously); - var schedulerAwaitable = AsyncContext.From(NewThreadTaskScheduler.Instance) + var schedulerAwaitable = AsyncContext.From(CustomTaskScheduler.Instance) .SwitchContextAsync(true, CancellationToken.None); schedulerAwaitable.OnCompleted(scheduled.SetResult); await scheduled.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)).ConfigureAwait(false); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs index d16f55b3..34bdf2e3 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs @@ -69,19 +69,26 @@ public async Task WhenMergeObservableOfObservables_ThenFlattens() public async Task WhenMergeWithMaxConcurrency_ThenRespectsLimit() { const int SourceCount = 5; - const int WorkDelayMilliseconds = 50; const int ConcurrencyLimit = 2; var activeConcurrency = 0; var maxConcurrency = 0; + + // Each job parks until the gate opens, so the limit is observed with every slot occupied. + TaskCompletionSource limitReached = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseJobs = new(TaskCreationOptions.RunContinuationsAsynchronously); var source = SignalAsync.Range(1, SourceCount).Select(i => SignalAsync.CreateAsBackgroundJob(async (obs, ct) => { lock (_gate) { activeConcurrency++; maxConcurrency = Math.Max(maxConcurrency, activeConcurrency); + if (activeConcurrency == ConcurrencyLimit) + { + IgnoredResult.Of(limitReached.TrySetResult()); + } } - await Task.Delay(WorkDelayMilliseconds, ct); + await releaseJobs.Task; lock (_gate) { activeConcurrency--; @@ -90,7 +97,10 @@ public async Task WhenMergeWithMaxConcurrency_ThenRespectsLimit() await obs.OnNextAsync(i, ct); await obs.OnCompletedAsync(Result.Success); })); - var result = await source.Merge(ConcurrencyLimit).ToListAsync(); + var merged = source.Merge(ConcurrencyLimit).ToListAsync().AsTask(); + await limitReached.Task; + IgnoredResult.Of(releaseJobs.TrySetResult()); + var result = await merged; await Assert.That(result).Count().IsEqualTo(SourceCount); await Assert.That(maxConcurrency).IsLessThanOrEqualTo(ConcurrencyLimit); } @@ -121,7 +131,6 @@ public async Task WhenMergeObservableOfObservablesOuterErrors_ThenFailurePropaga [Test] public async Task WhenMergeWithMaxConcurrencySubscriptionThrows_ThenErrorPropagates() { - const int CompletionTimeoutSeconds = 2; var failing = SignalAsync.Create(static (_, _) => { try @@ -134,15 +143,14 @@ public async Task WhenMergeWithMaxConcurrencySubscriptionThrows_ThenErrorPropaga } }); var source = new[] { failing }.ToAsyncSignal(); - Result? completionResult = null; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await source.Merge(1).SubscribeAsync(static (_, _) => default, null, result => { - completionResult = result; + IgnoredResult.Of(completed.TrySetResult(result)); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, TimeSpan.FromSeconds(CompletionTimeoutSeconds)); - await Assert.That(completionResult).IsNotNull(); - await Assert.That(completionResult!.Value.IsFailure).IsTrue(); + var completionResult = await completed.Task; + await Assert.That(completionResult.IsFailure).IsTrue(); } /// Verifies that merging an enumerable of observables where one inner source errors propagates the failure. @@ -422,8 +430,6 @@ public async Task WhenMergeEnumerableInnerCompletesAsynchronously_ThenAwaitsSubs [Test] public async Task WhenMergeEnumerableBeginSubscribingThrows_ThenCatchBlockHandled() { - const int CompletionTimeoutSeconds = 5; - // BeginSubscribing contains an async void path that catches exceptions // We exercise this by ensuring an error during inner subscription is caught static IEnumerable> ThrowingEnumerable() @@ -432,7 +438,7 @@ static IEnumerable> ThrowingEnumerable() throw new InvalidOperationException("enumeration boom"); } - Result? completionResult = null; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); List items = []; await using var sub = await ThrowingEnumerable().Merge().SubscribeAsync( (x, _) => @@ -443,12 +449,11 @@ static IEnumerable> ThrowingEnumerable() null, result => { - completionResult = result; + IgnoredResult.Of(completed.TrySetResult(result)); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult is not null, TimeSpan.FromSeconds(CompletionTimeoutSeconds)); - await Assert.That(completionResult).IsNotNull(); - await Assert.That(completionResult!.Value.IsFailure).IsTrue(); + var completionResult = await completed.Task; + await Assert.That(completionResult.IsFailure).IsTrue(); } /// Tests that MergeEnumerable cancellation during inner subscription is handled. @@ -515,10 +520,9 @@ public async Task WhenMergeEmptyEnumerable_ThenReturnsEmpty() [Test] public async Task WhenMergeWithError_ThenErrorPropagated() { - const int CompletionTimeoutSeconds = 5; IObservableAsync[] sources = [SignalAsync.Return(1), SignalAsync.Throw(new InvalidOperationException("fail"))]; - Result? completionResult = null; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); List items = []; await using var sub = await sources.Merge().SubscribeAsync( (x, _) => @@ -529,12 +533,11 @@ public async Task WhenMergeWithError_ThenErrorPropagated() null, result => { - completionResult = result; + IgnoredResult.Of(completed.TrySetResult(result)); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, TimeSpan.FromSeconds(CompletionTimeoutSeconds)); - await Assert.That(completionResult).IsNotNull(); - await Assert.That(completionResult!.Value.IsFailure).IsTrue(); + var completionResult = await completed.Task; + await Assert.That(completionResult.IsFailure).IsTrue(); } /// Verifies that MergeEnumerable forwards errors from a source that throws during subscribe. @@ -542,19 +545,17 @@ public async Task WhenMergeWithError_ThenErrorPropagated() [Test] public async Task WhenMergeEnumerableSourceThrowsDuringSubscribe_ThenCompletesWithFailure() { - const int CompletionTimeoutSeconds = 5; var throwingSource = SignalAsync.Create(static (_, _) => ValueTask.FromException(new InvalidOperationException(SubscribeBoomMessage))); IObservableAsync[] sources = [throwingSource]; - Result? completionResult = null; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await sources.Merge().SubscribeAsync(static (_, _) => default, null, result => { - completionResult = result; + IgnoredResult.Of(completed.TrySetResult(result)); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, TimeSpan.FromSeconds(CompletionTimeoutSeconds)); - await Assert.That(completionResult).IsNotNull(); - await Assert.That(completionResult!.Value.IsFailure).IsTrue(); + var completionResult = await completed.Task; + await Assert.That(completionResult.IsFailure).IsTrue(); } /// @@ -565,7 +566,6 @@ public async Task WhenMergeEnumerableSourceThrowsDuringSubscribe_ThenCompletesWi [Test] public async Task WhenMergeEnumerableInnerSubscribeThrowsTaskCanceled_ThenHandledGracefully() { - const int SettleTimeoutSeconds = 2; var canceledSource = SignalAsync.Create(static (_, _) => ValueTask.FromException(new TaskCanceledException("subscribe canceled"))); List items = []; @@ -580,7 +580,6 @@ public async Task WhenMergeEnumerableInnerSubscribeThrowsTaskCanceled_ThenHandle // The TaskCanceledException catch returns early without signaling completion, // so the sequence yields nothing (graceful early return). - await AsyncTestHelpers.WaitForConditionAsync(static () => true, TimeSpan.FromSeconds(SettleTimeoutSeconds)); await Assert.That(items).IsEmpty(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs index e451d0dc..e52e1bac 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs @@ -18,9 +18,6 @@ public class ConcurrentSignalBaseTests /// 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] @@ -251,13 +248,14 @@ private static CallbackWitnessAsync MakeSync(IntCapture capture) => return default; }); - /// Creates an OnNext observer that delays before capturing — forces the slow path. + /// Creates an OnNext observer that suspends before capturing — forces the slow path. /// The capture sink. /// An observer whose OnNextAsync completes asynchronously. private static CallbackWitnessAsync MakeSlow(IntCapture capture) => - new(async (x, ct) => + new(async (x, _) => { - await Task.Delay(SlowPathDelayMilliseconds, ct).ConfigureAwait(false); + // Yielding guarantees the returned ValueTask is incomplete when the fan-out inspects it. + await Task.Yield(); capture.Value = x; }); @@ -271,13 +269,13 @@ private static Func MakeErrorSync(Error return default; }; - /// OnErrorResume handler that delays before recording — forces the slow path. + /// OnErrorResume handler that suspends before recording — forces the slow path. /// The capture sink. /// An OnErrorResume delegate. private static Func MakeErrorSlow(ErrorCapture capture) => - async (ex, ct) => + async (ex, _) => { - await Task.Delay(SlowPathDelayMilliseconds, ct).ConfigureAwait(false); + await Task.Yield(); capture.Error = ex; }; @@ -291,13 +289,13 @@ private static Func MakeCompletedSync(ResultCapture capture) return default; }; - /// OnCompleted handler that delays before recording — forces the slow path. + /// OnCompleted handler that suspends 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); + await Task.Yield(); capture.Result = r; }; diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs new file mode 100644 index 00000000..e5c64fdd --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs @@ -0,0 +1,38 @@ +// 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.Primitives.Async.Tests; + +/// +/// A that is not and runs each queued task +/// on the thread pool. Used to exercise the code paths that branch on a caller-supplied scheduler. +/// +internal sealed class CustomTaskScheduler : TaskScheduler +{ + /// Singleton instance. + internal static readonly CustomTaskScheduler Instance = new(); + + /// Initializes a new instance of the class. + private CustomTaskScheduler() + { + } + + /// + protected override void QueueTask(Task task) => + ThreadPool.UnsafeQueueUserWorkItem( + static state => IgnoredResult.Of(state.Scheduler.ExecuteQueued(state.Work)), + (Scheduler: this, Work: task), + false); + + /// + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + + /// + protected override IEnumerable? GetScheduledTasks() => null; + + /// Runs a queued task on the pool thread that picked it up. + /// The queued task. + /// when the task was executed. + private bool ExecuteQueued(Task task) => TryExecuteTask(task); +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs index c46ea95e..5637fc99 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs @@ -798,13 +798,13 @@ public void WhenToDisposableAsyncNull_ThenThrowsArgumentNull() => Assert.Throws(static () => ((IDisposable)null!).ToDisposableAsync()); /// - /// Verifies that the CAS retry loop in SetDisposableAsync is exercised - /// when another thread mutates _current between the Volatile.Read and the - /// CompareExchange, forcing the loop to re-read and retry. + /// Verifies that concurrent SetDisposableAsync calls account for every disposable: each + /// replaced occupant is disposed as it leaves the slot and the final occupant is disposed + /// by the slot itself, whichever order the compare-exchanges land in. /// /// A representing the asynchronous test operation. [Test] - public async Task WhenSerialCASRetryLoop_ThenAllDisposablesAccountedFor() + public async Task WhenSerialSetConcurrently_ThenAllDisposablesAccountedFor() { SingleReplaceableDisposableAsync serial = new(); StrongBox disposedCount = new(); @@ -815,16 +815,13 @@ IAsyncDisposable MakeDisposable() => DisposableAsync.Create(disposedCount, stati return default; }); - // Bounded contention: enough parallel sets to race on CAS, small enough to run - // deterministically on any CI runner. Previous 20x10 layout produced unbounded - // variance (observed: 3s → 15s → never completed) and could timeout the suite. + // Bounded contention: enough parallel sets to overlap on the CAS, few enough that the + // total disposal count stays small. The count below holds for every interleaving. const int Parallelism = 4; const int IterationsPerTask = 5; const int ExpectedDisposedCount = Parallelism * IterationsPerTask; - Barrier barrier = new(Parallelism); var tasks = Enumerable.Range(0, Parallelism).Select(_ => Task.Run(async () => { - barrier.SignalAndWait(); for (var i = 0; i < IterationsPerTask; i++) { await serial.SetDisposableAsync(MakeDisposable()); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs index cf96858e..40ce0ccc 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs @@ -138,7 +138,7 @@ public async Task WhenRetryOnTransientError_ThenSucceedsAfterRetry() await obs.OnNextAsync(SuccessValue, ct); await obs.OnCompletedAsync(Result.Success); }, - NewThreadTaskScheduler.Instance); + CustomTaskScheduler.Instance); var result = await source.Retry(RetryCount).ToListAsync(); await Assert.That(result).IsCollectionEqualTo([SuccessValue]); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); @@ -232,7 +232,7 @@ public async Task WhenRetryWithCountZero_ThenPropagatesErrorImmediately() attempt++; await obs.OnCompletedAsync(Result.Failure(new InvalidOperationException($"attempt {attempt}"))); }, - NewThreadTaskScheduler.Instance); + CustomTaskScheduler.Instance); await using var sub = await source.Retry(0).SubscribeAsync(static (_, _) => default, null, result => { _ = completed.TrySetResult(result); @@ -259,7 +259,7 @@ public async Task WhenRetryCountExhausted_ThenPropagatesLastError() attempt++; await obs.OnCompletedAsync(Result.Failure(new InvalidOperationException($"attempt {attempt}"))); }, - NewThreadTaskScheduler.Instance); + CustomTaskScheduler.Instance); await using var sub = await source.Retry(RetryCount).SubscribeAsync(static (_, _) => default, null, result => { _ = completed.TrySetResult(result); @@ -284,7 +284,7 @@ public async Task WhenRetryWithCountOne_ThenRetriesOnceAndPropagates() attempt++; await obs.OnCompletedAsync(Result.Failure(new InvalidOperationException($"attempt {attempt}"))); }, - NewThreadTaskScheduler.Instance); + CustomTaskScheduler.Instance); await using var sub = await source.Retry(1).SubscribeAsync(static (_, _) => default, null, result => { _ = completed.TrySetResult(result); @@ -489,7 +489,7 @@ public async Task WhenRetryParameterless_ThenRetriesUntilSuccess() await obs.OnNextAsync(SuccessValue, ct); await obs.OnCompletedAsync(Result.Success); }, - NewThreadTaskScheduler.Instance); + CustomTaskScheduler.Instance); var result = await source.Retry().ToListAsync(); await Assert.That(result).IsCollectionEqualTo([SuccessValue]); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs index 31c3a7e1..045e69b5 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs @@ -18,9 +18,6 @@ public class FactorySignalTests /// Hoisted source array used by tests (was inline literal). private static readonly int[] Sequence123 = [1, 2, 3]; - /// Maximum time a test waits for an emission or completion to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Delay before the single-shot timer fires. private static readonly TimeSpan SingleShotDelay = TimeSpan.FromMilliseconds(50); @@ -85,15 +82,12 @@ public async Task WhenThrow_ThenCompletesWithException() public void WhenThrowNullException_ThenThrowsArgumentNull() => Assert.Throws(static () => SignalAsync.Throw(null!)); - /// Tests Never does not complete within timeout. + /// Tests Never neither emits nor completes, including once its subscription token is cancelled. /// A representing the asynchronous test operation. [Test] - public async Task WhenNever_ThenDoesNotCompleteWithinTimeout() + public async Task WhenNever_ThenNeitherEmitsNorCompletes() { - const int ObservationWindowMs = 250; - const int SubscriptionCancelledAfterMs = 200; - - using CancellationTokenSource cts = new(SubscriptionCancelledAfterMs); + using CancellationTokenSource cts = new(); List items = []; var completed = false; await using var sub = await SignalAsync.Never().SubscribeAsync( @@ -109,7 +103,7 @@ public async Task WhenNever_ThenDoesNotCompleteWithinTimeout() return default; }, cts.Token); - await Task.Delay(ObservationWindowMs); + await cts.CancelAsync(); await Assert.That(items).IsEmpty(); await Assert.That(completed).IsFalse(); } @@ -253,7 +247,7 @@ static async (observer, ct) => await observer.OnNextAsync(SentinelValue, ct); await observer.OnCompletedAsync(Result.Success); }, - NewThreadTaskScheduler.Instance); + CustomTaskScheduler.Instance); var result = await source.ToListAsync(); await Assert.That(result).IsCollectionEqualTo([SentinelValue]); } @@ -277,14 +271,20 @@ public async Task WhenTimerPeriodic_ThenEmitsMultipleValues() const int MinimumEmissions = 2; var source = SignalAsync.Timer(PeriodicDueTime, PeriodicInterval); List items = []; + TaskCompletionSource minimumReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await source.SubscribeAsync( (x, _) => { items.Add(x); + if (items.Count >= MinimumEmissions) + { + IgnoredResult.Of(minimumReceived.TrySetResult()); + } + return default; }, null); - await AsyncTestHelpers.WaitForConditionAsync(() => items.Count >= MinimumEmissions, WaitTimeout); + await minimumReceived.Task; await Assert.That(items.Count).IsGreaterThanOrEqualTo(MinimumEmissions); await Assert.That(items[0]).IsEqualTo(0L); } @@ -374,6 +374,7 @@ public async Task WhenIntervalWithCancellation_ThenEmitsPeriodicValues() using CancellationTokenSource cts = new(); var source = SignalAsync.Interval(PeriodicInterval); List items = []; + TaskCompletionSource minimumReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); var received = false; try { @@ -381,14 +382,18 @@ public async Task WhenIntervalWithCancellation_ThenEmitsPeriodicValues() (x, _) => { items.Add(x); + if (items.Count >= MinimumEmissions) + { + IgnoredResult.Of(minimumReceived.TrySetResult()); + } + return default; }, null, null, cts.Token); - received = await AsyncTestHelpers.WaitForConditionAsync( - () => items.Count >= MinimumEmissions, - WaitTimeout); + await minimumReceived.Task; + received = true; } catch (OperationCanceledException) { @@ -450,7 +455,7 @@ public async Task WhenSubscribeAsyncWithOnNextAsyncOnly_ThenReceivesItems() IgnoredResult.Of(received.TrySetResult()); return default; }); - await received.Task.WaitAsync(WaitTimeout); + await received.Task; await Assert.That(items).IsCollectionEqualTo([EmittedValue]); } @@ -471,7 +476,7 @@ public async Task WhenSubscribeAsyncWithOnNextAsyncAndCancellationToken_ThenRece return default; }, cts.Token); - await received.Task.WaitAsync(WaitTimeout); + await received.Task; await Assert.That(items).IsCollectionEqualTo([EmittedValue]); } @@ -492,7 +497,7 @@ public async Task WhenSubscribeAsyncSyncOverloadWithError_ThenInvokesOnErrorResu ex => errorReceived.TrySetResult(ex), null, CancellationToken.None); - var error = await errorReceived.Task.WaitAsync(WaitTimeout); + var error = await errorReceived.Task; await Assert.That(error).IsTypeOf(); await Assert.That(error.Message).IsEqualTo("sync error"); } @@ -508,7 +513,7 @@ public async Task WhenSubscribeAsyncSyncOverloadWithCompletion_ThenInvokesOnComp null, r => completedResult.TrySetResult(r), CancellationToken.None); - var result = await completedResult.Task.WaitAsync(WaitTimeout); + var result = await completedResult.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -524,7 +529,7 @@ public async Task WhenSubscribeAsyncWithNullOnErrorResume_ThenCompletesNormally( null, _ => completed.TrySetResult(), CancellationToken.None); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).IsCollectionEqualTo([SentinelValue]); } @@ -544,7 +549,7 @@ public async Task WhenSubscribeAsyncWithNullOnCompleted_ThenCompletesNormally() static _ => { }, null, CancellationToken.None); - await received.Task.WaitAsync(WaitTimeout); + await received.Task; await Assert.That(items).IsCollectionEqualTo([SentinelValue]); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/NewThreadTaskScheduler.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/NewThreadTaskScheduler.cs deleted file mode 100644 index 2e25ab02..00000000 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/NewThreadTaskScheduler.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace ReactiveUI.Primitives.Async.Tests; - -/// -/// A that launches each queued task on its own dedicated background thread. -/// Provides deterministic concurrency for tests that need real parallel execution without competing for -/// limited resources (which can deadlock when many tests run in parallel and each -/// blocks waiting on inner work that also needs a pool thread). -/// -internal sealed class NewThreadTaskScheduler : TaskScheduler -{ - /// Singleton instance. - internal static readonly NewThreadTaskScheduler Instance = new(); - - /// Initializes a new instance of the class. - private NewThreadTaskScheduler() - { - } - - /// - protected override void QueueTask(Task task) => - new Thread(state => TryExecuteTask((Task)state!)) { IsBackground = true, Name = "NewThreadTaskScheduler" } - .Start(task); - - /// - protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; - - /// - protected override IEnumerable? GetScheduledTasks() => null; -} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs index a664806e..ae1a4e6a 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs @@ -22,18 +22,15 @@ public sealed class ObserverAsyncDisposeTests /// private const int DisposeRaceAttempts = 256; - /// Maximum time a reentrant dispose may take before it is treated as a deadlock. - private static readonly TimeSpan DeadlockTimeout = TimeSpan.FromSeconds(5); - /// Verifies the reentrant dispose path lets an observer dispose itself from within its own in-flight /// notification without deadlocking, even after the notification continuation has hopped to a different thread. - /// A task that completes when disposal finishes; faults on timeout if a self-join deadlock occurs. + /// A task that completes when disposal finishes; a self-join deadlock leaves it pending. [Test] public async Task WhenDisposedReentrantlyFromOwnNotificationAfterThreadHop_ThenDoesNotDeadlock() { SelfDisposingObserver observer = new(); - await observer.OnNextAsync(1, CancellationToken.None).AsTask().WaitAsync(DeadlockTimeout); + await observer.OnNextAsync(1, CancellationToken.None); await Assert.That(observer.HasDisposed).IsTrue(); } @@ -51,7 +48,7 @@ public async Task WhenFirstAsyncResolvesDuringHoppedNotification_ThenCompletes() return DisposableAsync.Empty; }); - var value = await source.FirstAsync().AsTask().WaitAsync(DeadlockTimeout); + var value = await source.FirstAsync(); await Assert.That(value).IsEqualTo(EmittedValue); } @@ -81,19 +78,19 @@ public async Task WhenLinkedToItsOwnDisposeToken_ThenExternalLinkStillDisposesTh /// Verifies that disposing an observer from one thread while a notification is still in flight on /// another never hangs, including when that notification's call count drops to zero inside the disposer's /// publish-then-recheck window — the case the disposer must self-signal to avoid waiting forever. - /// A task that completes when every attempt has disposed; faults on timeout if a wait deadlocks. + /// A task that completes when every attempt has disposed; a deadlocked wait leaves it pending. [Test] public async Task WhenDisposedFromAnotherThreadAsNotificationExits_ThenDoesNotDeadlock() { for (var attempt = 0; attempt < DisposeRaceAttempts; attempt++) { - SpinningObserver observer = new(); + ParkedNotificationObserver observer = new(); var notification = Task.Run(async () => await observer.OnNextAsync(EmittedValue, CancellationToken.None)); - await observer.Entered.WaitAsync(DeadlockTimeout); - await observer.DisposeAsync().AsTask().WaitAsync(DeadlockTimeout); - await notification.WaitAsync(DeadlockTimeout); + await observer.Entered; + await observer.DisposeAsync(); + await notification; await Assert.That(observer.HasDisposed).IsTrue(); } @@ -121,33 +118,24 @@ protected override ValueTask OnErrorResumeAsyncCore(Exception error, Cancellatio protected override ValueTask OnCompletedAsyncCore(Result result) => default; } - /// Observer whose notification stays in flight, spinning, until disposal releases it — so the call - /// exits within nanoseconds of the disposer starting, rather than parking and exiting long afterwards. - private sealed class SpinningObserver : WitnessAsync + /// Observer whose notification stays in flight until disposal releases it, so the call exits while the + /// disposer is inside its publish-then-recheck window rather than long before or long after it. + private sealed class ParkedNotificationObserver : WitnessAsync { - /// Upper bound on the spin the in-flight notification performs while waiting to be released. - private const int MaxReleaseSpins = 10_000_000; - /// Completes once the notification has been entered and the in-flight call count is non-zero. private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); - /// Non-zero once disposal has released the spinning notification. - private int _released; + /// Completes when disposal releases the parked notification. + private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously); /// Gets a task that completes once the notification is in flight. internal Task Entered => _entered.Task; /// - protected override ValueTask OnNextAsyncCore(int value, CancellationToken cancellationToken) + protected override async ValueTask OnNextAsyncCore(int value, CancellationToken cancellationToken) { IgnoredResult.Of(_entered.TrySetResult()); - - for (var spin = 0; spin < MaxReleaseSpins && Volatile.Read(ref _released) == 0; spin++) - { - Thread.SpinWait(1); - } - - return default; + await _released.Task.ConfigureAwait(false); } /// @@ -160,7 +148,8 @@ protected override ValueTask OnErrorResumeAsyncCore(Exception error, Cancellatio /// protected override ValueTask DisposeAsyncCore() { - Volatile.Write(ref _released, 1); + // Release before the base waits on in-flight calls: the notification exits as the gate is published. + IgnoredResult.Of(_released.TrySetResult()); return base.DisposeAsyncCore(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs index 9998669b..3cac112f 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs @@ -285,7 +285,7 @@ public async Task WhenPartitionSourceErrorResume_ThenBothBranchesReceiveError() InvalidOperationException expected = new("partition-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await Task.WhenAll(evenTcs.Task, oddTcs.Task).WaitAsync(WaitTimeout); + await Task.WhenAll(evenTcs.Task, oddTcs.Task); await Assert.That(evenError).IsSameReferenceAs(expected); await Assert.That(oddError).IsSameReferenceAs(expected); } @@ -319,7 +319,7 @@ public async Task WhenPartitionLateBranchSubscribesAfterCompletion_ThenCachedTer return default; }); - await lateCompleted.Task.WaitAsync(WaitTimeout); + await lateCompleted.Task; await Assert.That(lateValues).IsEmpty(); } @@ -375,7 +375,7 @@ public async Task WhenScanWithInitialSourceErrorResumes_ThenForwardsDownstream() InvalidOperationException expected = new("scan-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); await Assert.That(values).IsCollectionEqualTo([ScanSeed]); } @@ -403,7 +403,7 @@ public async Task WhenThrottleDistinctSourceErrorResumes_ThenForwardsDownstream( InvalidOperationException expected = new("throttle-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -430,7 +430,7 @@ public async Task WhenDebounceUntilSourceErrorResumes_ThenForwardsDownstream() InvalidOperationException expected = new("debounce-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -457,7 +457,7 @@ public async Task WhenForEachSourceErrorResumes_ThenForwardsDownstream() InvalidOperationException expected = new("foreach-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -484,7 +484,7 @@ public async Task WhenDropIfBusySourceErrorResumes_ThenForwardsDownstream() InvalidOperationException expected = new("dropifbusy-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -509,7 +509,7 @@ public async Task WhenDropIfBusySyncActionAsyncDownstream_ThenAwaitForwardSlowPa }); await signal.OnNextAsync(One, CancellationToken.None); - await emittedTcs.Task.WaitAsync(WaitTimeout); + await emittedTcs.Task; // After the slow path resets _isBusy, a second emission must also flow through. TaskCompletionSource secondTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -522,7 +522,7 @@ public async Task WhenDropIfBusySyncActionAsyncDownstream_ThenAwaitForwardSlowPa }); await signal.OnNextAsync(Two, CancellationToken.None); - await secondTcs.Task.WaitAsync(WaitTimeout); + await secondTcs.Task; await Assert.That(values).Contains(One); } @@ -550,7 +550,7 @@ public async Task WhenScanWithInitialAsyncSourceErrorResumes_ThenForwardsDownstr InvalidOperationException expected = new("scan-async-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -665,8 +665,7 @@ public async Task WhenDebounceUntilSecondEmissionSupersedesFirst_ThenStaleDelayD await signal.OnNextAsync(One, CancellationToken.None); await signal.OnNextAsync(Two, CancellationToken.None); - await emitted.Task.WaitAsync(WaitTimeout); - await Task.Delay(ThrottleWindowMilliseconds); + await emitted.Task; await Assert.That(values).IsCollectionEqualTo([Two]); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs index d0425c8b..3763de44 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs @@ -14,9 +14,6 @@ public partial class TakeUntilOperatorTests /// String literal "subscribe failed" used by multiple tests. private const string SubscribeFailedMessage = "subscribe failed"; - /// How long a test waits to prove that an ignored second stop notification never surfaces. - private static readonly TimeSpan SecondNotificationSettleWindow = TimeSpan.FromMilliseconds(250); - /// Tests that CompletionSignalDelegate failure signal with SourceFailsWhenOtherFails=true completes with failure. /// A representing the asynchronous test operation. [Test] @@ -209,7 +206,7 @@ public async Task WhenTakeUntilTokenCanceledDuringEmission_ThenEmissionStops() await source.OnNextAsync(1, CancellationToken.None); await source.OnNextAsync(SecondItem, CancellationToken.None); await cts.CancelAsync(); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(items).Contains(1); await Assert.That(items).Contains(SecondItem); await Assert.That(completionResult.IsSuccess).IsTrue(); @@ -266,7 +263,7 @@ public async Task WhenTaskStopSignalCompletesMidStream_ThenStopsEmissions() await source.OnNextAsync(1, CancellationToken.None); await source.OnNextAsync(SecondItem, CancellationToken.None); tcs.SetResult(); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(items).Contains(1); await Assert.That(items).Contains(SecondItem); await Assert.That(completionResult.IsSuccess).IsTrue(); @@ -394,25 +391,32 @@ public async Task WhenCancellationStopSignal_ThenStopsWhenCanceled() public async Task WhenTakeUntilCompletionDelegate_ThenStopsWhenSignalCompletes() { var signal = Signal.Create(); + Action? notifyStop = null; List items = []; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await signal.Values.TakeUntil((CompletionSignalDelegate)CompletionSignal).SubscribeAsync( (x, _) => { items.Add(x); return default; }, - null); + null, + _ => + { + IgnoredResult.Of(completed.TrySetResult()); + return default; + }); await signal.OnNextAsync(1, CancellationToken.None); await Assert.That(items).Contains(1); - static IAsyncDisposable CompletionSignal(Action notifyStop) + notifyStop!(Result.Success); + await completed.Task; + await signal.OnNextAsync(SecondItem, CancellationToken.None); + await Assert.That(items).IsCollectionEqualTo([1]); + + IAsyncDisposable CompletionSignal(Action notify) { - const int Delay = 100; - _ = Task.Run(async () => - { - await Task.Delay(Delay); - notifyStop(Result.Success); - }); + notifyStop = notify; return DisposableAsync.Empty; } } @@ -426,17 +430,14 @@ static IAsyncDisposable CompletionSignal(Action notifyStop) public async Task WhenTakeUntilCompletionDelegateFailsAndOptionFalse_ThenErrorResumeForwarded() { var source = Signal.Create(); + Action? notifyStop = null; Exception? errorResumed = null; List items = []; + TaskCompletionSource errorForwarded = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await source.Values.TakeUntil( stop => { - // Signal failure after a brief delay - _ = Task.Run(async () => - { - await Task.Yield(); - stop(Result.Failure(new InvalidOperationException("signal fail"))); - }); + notifyStop = stop; return DisposableAsync.Empty; }, new TakeUntilOptions { SourceFailsWhenOtherFails = false }).SubscribeAsync( @@ -448,10 +449,12 @@ public async Task WhenTakeUntilCompletionDelegateFailsAndOptionFalse_ThenErrorRe (ex, _) => { errorResumed = ex; + IgnoredResult.Of(errorForwarded.TrySetResult()); return default; }); await source.OnNextAsync(1, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => errorResumed is not null, WaitTimeout); + notifyStop!(Result.Failure(new InvalidOperationException("signal fail"))); + await errorForwarded.Task; await Assert.That(errorResumed).IsNotNull(); await Assert.That(errorResumed!.Message).IsEqualTo("signal fail"); } @@ -467,16 +470,18 @@ public async Task WhenTaskStopSignalFailsAndOptionFalse_ThenErrorResumeForwarded var source = Signal.Create(); TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); Exception? errorResumed = null; + TaskCompletionSource errorForwarded = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await source.Values .TakeUntil(tcs.Task, new TakeUntilOptions { SourceFailsWhenOtherFails = false }).SubscribeAsync( static (_, _) => default, (ex, _) => { errorResumed = ex; + IgnoredResult.Of(errorForwarded.TrySetResult()); return default; }); tcs.SetException(new InvalidOperationException("task fail")); - await AsyncTestHelpers.WaitForConditionAsync(() => errorResumed is not null, WaitTimeout); + await errorForwarded.Task; await Assert.That(errorResumed).IsNotNull(); await Assert.That(errorResumed!.Message).IsEqualTo("task fail"); } @@ -511,7 +516,7 @@ public async Task WhenTakeUntilCompletionDelegateWithCancellableToken_ThenLinked using CancellationTokenSource cts = new(); var source = Signal.Create(); List values = []; - Result? completionResult = null; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); CompletionSignalDelegate stopSignal = static _ => DisposableAsync.Empty; await using var sub = await source.Values.TakeUntil(stopSignal, null, cts.Token).SubscribeAsync( (x, _) => @@ -522,15 +527,15 @@ public async Task WhenTakeUntilCompletionDelegateWithCancellableToken_ThenLinked null, result => { - completionResult = result; + IgnoredResult.Of(completed.TrySetResult(result)); return default; }); const int Sentinel = 31; await source.OnNextAsync(Sentinel, CancellationToken.None); await cts.CancelAsync(); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, WaitTimeout); + var completionResult = await completed.Task; await Assert.That(values).IsCollectionEqualTo([Sentinel]); - await Assert.That(completionResult).IsNotNull(); + await Assert.That(completionResult.IsSuccess).IsTrue(); } /// Verifies the TakeUntil(other, options, cancellationToken) overload wraps the @@ -560,7 +565,7 @@ public async Task WhenTakeUntilOtherWithOptionsAndCancellableToken_ThenCancellat }); await source.OnNextAsync(1, CancellationToken.None); await cts.CancelAsync(); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(values).IsCollectionEqualTo([1]); await Assert.That(completionResult.IsSuccess).IsTrue(); } @@ -589,7 +594,7 @@ public async Task WhenTakeUntilOtherWithOptionsAndUncancellableToken_ThenOtherFa }); await source.OnNextAsync(1, CancellationToken.None); await other.OnCompletedAsync(Result.Failure(new InvalidOperationException("other failed"))); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); } @@ -620,7 +625,7 @@ public async Task WhenTaskStopSignalWithOptionsAndCancellableToken_ThenCancellat }); await source.OnNextAsync(1, CancellationToken.None); await cts.CancelAsync(); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(values).IsCollectionEqualTo([1]); await Assert.That(completionResult.IsSuccess).IsTrue(); } @@ -649,7 +654,7 @@ public async Task WhenTaskStopSignalWithOptionsAndUncancellableToken_ThenTaskFai }); await source.OnNextAsync(1, CancellationToken.None); stopTask.SetException(new InvalidOperationException("task failed")); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); } @@ -674,13 +679,10 @@ public async Task WhenTakeUntilCompletionDelegateNotifiesBeforeReturning_ThenSto return default; }); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); - var released = await AsyncTestHelpers.WaitForConditionAsync( - () => registration.DisposeCount == 1, - WaitTimeout); + var completionResult = await completed.Task; + await registration.Disposed; await Assert.That(completionResult.IsSuccess).IsTrue(); - await Assert.That(released).IsTrue(); await Assert.That(registration.DisposeCount).IsEqualTo(1); IAsyncDisposable StopSignal(Action notify) @@ -721,14 +723,12 @@ public async Task WhenTakeUntilCompletionDelegateNotifiesTwice_ThenSecondNotific }); notifyStop!(Result.Success); - await firstCompletion.Task.WaitAsync(WaitTimeout); + await firstCompletion.Task; + // The coordinator latches the first stop, so a second notification is dropped where it is raised + // and queues no further work to observe. notifyStop!(Result.Failure(new InvalidOperationException("second stop"))); - var leaked = await AsyncTestHelpers.WaitForConditionAsync( - () => errors.Count > 0 || completions.Count > 1, - SecondNotificationSettleWindow); - await Assert.That(leaked).IsFalse(); await Assert.That(completions).Count().IsEqualTo(1); await Assert.That(completions[0].IsSuccess).IsTrue(); await Assert.That(errors).IsEmpty(); @@ -737,16 +737,23 @@ public async Task WhenTakeUntilCompletionDelegateNotifiesTwice_ThenSecondNotific /// An that records how many times it has been disposed. private sealed class DisposeCountingAsyncDisposable : IAsyncDisposable { + /// Signals the first disposal so a test can await it instead of polling the count. + private readonly TaskCompletionSource _disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// The number of times has been called. private int _disposeCount; /// Gets the number of times has been called. internal int DisposeCount => Volatile.Read(ref _disposeCount); + /// Gets a task that completes on the first disposal. + internal Task Disposed => _disposed.Task; + /// public ValueTask DisposeAsync() { _ = Interlocked.Increment(ref _disposeCount); + IgnoredResult.Of(_disposed.TrySetResult()); return default; } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs index 874dae2d..1415c0ca 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs @@ -132,10 +132,10 @@ public async Task WhenThrottleWithSpacedItems_ThenAllAreEmitted() const int SpacingDelayMillis = 75; const int SecondValue = 2; await signal.OnNextAsync(1, CancellationToken.None); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Task.Delay(SpacingDelayMillis); await signal.OnNextAsync(SecondValue, CancellationToken.None); - await secondReceived.Task.WaitAsync(WaitTimeout); + await secondReceived.Task; await Assert.That(results).IsCollectionEqualTo([1, SecondValue]); } @@ -357,7 +357,7 @@ public async Task WhenThrottleWithCustomTimeProvider_ThenUsesTimerPath() await signal.OnNextAsync(1, CancellationToken.None); await signal.OnNextAsync(SecondValue, CancellationToken.None); await signal.OnNextAsync(LastValue, CancellationToken.None); - await resultReceived.Task.WaitAsync(WaitTimeout); + await resultReceived.Task; await signal.OnCompletedAsync(Result.Success); await Assert.That(results).Count().IsEqualTo(1); await Assert.That(results[0]).IsEqualTo(LastValue); @@ -391,7 +391,7 @@ public async Task WhenThrottleWithCustomTimeProviderValueSuperseded_ThenOlderVal // Emit two values rapidly; first should be superseded await signal.OnNextAsync(FirstValue, CancellationToken.None); await signal.OnNextAsync(LastValue, CancellationToken.None); - await resultReceived.Task.WaitAsync(WaitTimeout); + await resultReceived.Task; await signal.OnCompletedAsync(Result.Success); await Assert.That(results).Count().IsEqualTo(1); await Assert.That(results[0]).IsEqualTo(LastValue); @@ -450,7 +450,7 @@ public async Task WhenThrottleOnErrorResume_ThenCancelsTimerAndForwardsError() // Immediately send an error before the throttle timer fires await signal.OnErrorResumeAsync(new InvalidOperationException(TestErrorMessage), CancellationToken.None); - await errorReceived.Task.WaitAsync(WaitTimeout); + await errorReceived.Task; // Error should be forwarded, and the pending value should NOT be emitted await Assert.That(errors).Count().IsEqualTo(1); @@ -500,7 +500,7 @@ public async Task WhenTimeoutSourceEmitsErrorResume_ThenForwardsAndCancelsTimer( }); InvalidOperationException testError = new(TestErrorMessage); await source.EmitError(testError); - await errorReceived.Task.WaitAsync(WaitTimeout); + await errorReceived.Task; await Assert.That(errors).Count().IsEqualTo(1); await Assert.That(errors[0]).IsTypeOf(); await Assert.That(errors[0].Message).IsEqualTo(TestErrorMessage); @@ -532,7 +532,7 @@ public async Task WhenDelaySourceEmitsErrorResume_ThenErrorForwarded() InvalidOperationException expectedError = new("resume error"); await source.EmitError(expectedError); await source.Complete(Result.Success); - await completed.Task.WaitAsync(CompletionWaitTimeout); + await completed.Task; await Assert.That(errors).Count().IsEqualTo(1); await Assert.That(errors[0]).IsSameReferenceAs(expectedError); } @@ -569,7 +569,7 @@ public async Task WhenThrottleValueSuperseded_ThenOlderValueDropped() // Wait for the throttled value to arrive before completing await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, CompletionWaitTimeout); await signal.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(CompletionWaitTimeout); + await completed.Task; // Only the last value (2) should have been emitted await Assert.That(results).Contains(LastValue); @@ -700,7 +700,7 @@ public async Task WhenIntervalCancelled_ThenStops() null, null, cts.Token); - await cancelled.Task.WaitAsync(CompletionWaitTimeout); + await cancelled.Task; await Assert.That(items.Count).IsGreaterThanOrEqualTo(MinItemCount); } @@ -729,7 +729,7 @@ public async Task WhenTimerWithPeriodCancelled_ThenStops() null, null, cts.Token); - await cancelled.Task.WaitAsync(CompletionWaitTimeout); + await cancelled.Task; await Assert.That(items.Count).IsGreaterThanOrEqualTo(MinItemCount); } @@ -772,7 +772,7 @@ public async Task WhenThrottleReceivesRapidValues_ThenOnlyEmitsLatest() await Assert.That(manualProvider.TimerCount).IsEqualTo(LastValue); manualProvider.FireAll(); - await completed.Task.WaitAsync(CompletionWaitTimeout); + await completed.Task; await Assert.That(items).Contains(LastValue); } diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs index 686a3baa..7df6bbc4 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs @@ -11,17 +11,14 @@ namespace ReactiveUI.Primitives.Avalonia.Reactive.Tests; /// Tests for against a pumped Avalonia headless dispatcher. public sealed class AvaloniaSchedulerTests { - /// Maximum time to wait for dispatcher work. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Delay used to exercise the native dispatcher-timer path. private static readonly TimeSpan DelayedDueTime = TimeSpan.FromMilliseconds(50); /// Delay used for work that is cancelled before its timer fires. private static readonly TimeSpan CancellationDueTime = TimeSpan.FromMilliseconds(100); - /// Time allowed to prove cancelled dispatcher work remains inactive. - private static readonly TimeSpan CancellationWaitTime = TimeSpan.FromMilliseconds(150); + /// Delay for work that falls due after the cancelled work, on the same dispatcher and priority. + private static readonly TimeSpan FollowingDueTime = TimeSpan.FromMilliseconds(200); /// Verifies constructor validation. /// A task representing the asynchronous operation. @@ -45,8 +42,11 @@ static async () => /// Verifies immediate scheduler work is posted to and executed on the selected dispatcher thread. /// A task representing the asynchronous operation. [Test] - public async Task ImmediateScheduleExecutesOnDispatcherThread() => - await AvaloniaTestSession.Instance.Dispatch( + public async Task ImmediateScheduleExecutesOnDispatcherThread() + { + // The session only awaits a dispatched delegate that returns a result, so the facts to assert come back + // out of the dispatch; an assertion left inside it past the first await is never observed. + var (dispatcherThreadId, executionThreadId) = await AvaloniaTestSession.Instance.Dispatch( static async () => { var dispatcherThreadId = Environment.CurrentManagedThreadId; @@ -57,16 +57,19 @@ static async () => _ = scheduler.Schedule( () => completion.TrySetResult(Environment.CurrentManagedThreadId)); - var executionThreadId = await completion.Task.WaitAsync(WaitTimeout); - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + return (DispatcherThreadId: dispatcherThreadId, ExecutionThreadId: await completion.Task); }, CancellationToken.None); + await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + } + /// Verifies delayed scheduler work runs on a timer bound to the selected dispatcher. /// A task representing the asynchronous operation. [Test] - public async Task DelayedScheduleExecutesOnDispatcherThread() => - await AvaloniaTestSession.Instance.Dispatch( + public async Task DelayedScheduleExecutesOnDispatcherThread() + { + var (dispatcherThreadId, executionThreadId, priority) = await AvaloniaTestSession.Instance.Dispatch( static async () => { var dispatcherThreadId = Environment.CurrentManagedThreadId; @@ -78,20 +81,27 @@ static async () => DelayedDueTime, () => completion.TrySetResult(Environment.CurrentManagedThreadId)); - var executionThreadId = await completion.Task.WaitAsync(WaitTimeout); - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); - await Assert.That(scheduler.Priority).IsEqualTo(DispatcherPriority.Normal); + return ( + DispatcherThreadId: dispatcherThreadId, + ExecutionThreadId: await completion.Task, + scheduler.Priority); }, CancellationToken.None); + await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + await Assert.That(priority).IsEqualTo(DispatcherPriority.Normal); + } + /// Verifies disposing delayed work stops its dispatcher timer before execution. /// A task representing the asynchronous operation. [Test] - public async Task DelayedScheduleCanBeCancelled() => - await AvaloniaTestSession.Instance.Dispatch( + public async Task DelayedScheduleCanBeCancelled() + { + var executed = await AvaloniaTestSession.Instance.Dispatch( static async () => { AvaloniaScheduler scheduler = new(Dispatcher.UIThread); + TaskCompletionSource following = new(TaskCreationOptions.RunContinuationsAsynchronously); var executed = false; var disposable = scheduler.Schedule( @@ -99,8 +109,16 @@ static async () => () => executed = true); disposable.Dispose(); - await Task.Delay(CancellationWaitTime); - await Assert.That(executed).IsFalse(); + // The following work is due after the cancelled work and shares its dispatcher and priority, so the + // dispatcher passes the cancelled due time first: the follower running means the cancelled action was + // skipped rather than merely still pending. + _ = scheduler.Schedule(FollowingDueTime, following.SetResult); + + await following.Task; + return executed; }, CancellationToken.None); + + await Assert.That(executed).IsFalse(); + } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs index abbd90c9..b69dfef7 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs @@ -9,27 +9,27 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; /// Edge-case coverage for DropIfBusyObservable<T>. public class DropIfBusyObservableTests { - /// Delay used to let fire-and-forget async continuations settle. - private const int SettleDelayMilliseconds = 50; - - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies a handler completion after source completion does not emit the value. /// A representing the asynchronous test operation. [Test] public async Task WhenHandlerCompletesAfterSourceDone_ThenValueDropped() { Subject subject = new(); - TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // The release gate completes its continuations inline, so the handler's tail runs here. + TaskCompletionSource release = new(); + TaskCompletionSource handlerResumed = new(); List values = []; var completed = false; - using var sub = subject.DropIfBusy(async _ => await release.Task.ConfigureAwait(false)) - .Subscribe(values.Add, () => completed = true); + using var sub = subject.DropIfBusy(async _ => + { + await release.Task.ConfigureAwait(false); + handlerResumed.SetResult(); + }).Subscribe(values.Add, () => completed = true); subject.OnNext(1); subject.OnCompleted(); release.SetResult(); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + await handlerResumed.Task; await Assert.That(values).IsEmpty(); await Assert.That(completed).IsTrue(); } @@ -40,7 +40,9 @@ public async Task WhenHandlerCompletesAfterSourceDone_ThenValueDropped() public async Task WhenHandlerThrowsAfterSourceDone_ThenErrorDropped() { Subject subject = new(); - TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // The release gate completes its continuations inline, so the handler throws here. + TaskCompletionSource release = new(); InvalidOperationException expected = new("late-handler"); Exception? caught = null; var completed = false; @@ -55,7 +57,6 @@ public async Task WhenHandlerThrowsAfterSourceDone_ThenErrorDropped() subject.OnNext(1); subject.OnCompleted(); release.SetResult(); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); await Assert.That(caught).IsNull(); await Assert.That(completed).IsTrue(); } @@ -81,8 +82,8 @@ public async Task WhenSourceErrorsBeforeDone_ThenForwardsError() public async Task WhenHandlerThrowsBeforeDone_ThenForwardsError() { Subject subject = new(); - TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource error = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(); + TaskCompletionSource error = new(); InvalidOperationException expected = new("handler"); using var sub = subject.DropIfBusy(async _ => { @@ -93,7 +94,7 @@ public async Task WhenHandlerThrowsBeforeDone_ThenForwardsError() ex => error.TrySetResult(ex)); subject.OnNext(1); release.SetResult(); - var caught = await error.Task.WaitAsync(GuardTimeout).ConfigureAwait(false); + var caught = await error.Task; await Assert.That(caught).IsSameReferenceAs(expected); } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs index 32b7e7e6..47b1c064 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs @@ -7,6 +7,7 @@ using System.Reactive.Subjects; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; @@ -18,8 +19,8 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; /// notifications past the terminal to verify the guard silently drops them. public class OperatorAfterTerminalGuardTests { - /// Settle window used to let scheduler-marshalled tests fire any racing emission. - private const int SettleDelayMilliseconds = 50; + /// Retry delay handed to the retry operators under test. + private const int RetryDelayMilliseconds = 50; /// Tick window for fast-scheduler tests. private const int TickWindow = 100; @@ -30,9 +31,6 @@ public class OperatorAfterTerminalGuardTests /// Second sentinel value used in after-terminal pushes. private const int SecondValue = 2; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies OnErrorRetry's sink silently drops events after a downstream /// completion has set the _disposed latch — and that a second dispose hits the /// Interlocked.Exchange != 0 idempotency guard in . @@ -65,7 +63,7 @@ public async Task WhenRetryWithDelaySourceErrorAfterDispose_ThenDropped() { SyncDirectSource source = new(); Exception? caught = null; - var sub = source.RetryForeverWithDelay(TimeSpan.FromMilliseconds(SettleDelayMilliseconds)).Subscribe( + var sub = source.RetryForeverWithDelay(TimeSpan.FromMilliseconds(RetryDelayMilliseconds)).Subscribe( static _ => { }, ex => caught = ex); sub.Dispose(); @@ -84,7 +82,7 @@ public async Task WhenRetryWithDelaySourceErrorAfterDispose_ThenDropped() [Test] public async Task WhenRetryWithDelayDisposedDuringDelay_ThenSubscribeToSourceGuardSkipsRetry() { - const int LongDelayMs = 250; + VirtualClock scheduler = new(); var subscribeCount = 0; var source = Observable.Create(o => { @@ -92,14 +90,18 @@ public async Task WhenRetryWithDelayDisposedDuringDelay_ThenSubscribeToSourceGua o.OnError(new InvalidOperationException("retry-after-dispose")); return EmptyDisposable.Instance; }); - var sub = source.RetryForeverWithDelay(TimeSpan.FromMilliseconds(LongDelayMs)).Subscribe(static _ => { }); + var sub = new RetryWithDelayObservable( + source, + int.MaxValue, + static _ => TimeSpan.FromTicks(TickWindow), + scheduler).Subscribe(static _ => { }); // First subscribe ran; source errored synchronously and a retry has been scheduled. sub.Dispose(); - // Wait past the delay window so the scheduled callback fires while _disposed = true, + // Moving past the delay window fires the scheduled callback while _disposed = true, // hitting the SubscribeToSource _disposed guard rather than re-subscribing. - await Task.Delay(LongDelayMs + LongDelayMs); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); await Assert.That(subscribeCount).IsEqualTo(1); } @@ -108,8 +110,8 @@ public async Task WhenRetryWithDelayDisposedDuringDelay_ThenSubscribeToSourceGua [Test] public async Task WhenRetryWithBackoffDisposedDuringDelay_ThenSubscribeToSourceGuardSkipsRetry() { - const int LongDelayMs = 250; const int RetryAttempts = 10; + VirtualClock scheduler = new(); var subscribeCount = 0; var source = Observable.Create(o => { @@ -120,10 +122,10 @@ public async Task WhenRetryWithBackoffDisposedDuringDelay_ThenSubscribeToSourceG var sub = source.OnErrorRetry( static _ => { }, RetryAttempts, - TimeSpan.FromMilliseconds(LongDelayMs), - TaskPoolSequencer.Default).Subscribe(static _ => { }); + TimeSpan.FromTicks(TickWindow), + scheduler).Subscribe(static _ => { }); sub.Dispose(); - await Task.Delay(LongDelayMs + LongDelayMs); + scheduler.AdvanceBy(TickWindow * SettleMultiplier); await Assert.That(subscribeCount).IsEqualTo(1); } @@ -404,7 +406,7 @@ public async Task WhenRetryWithBackoffSourceErrorAfterDispose_ThenDropped() { SyncDirectSource source = new(); Exception? caught = null; - var sub = source.RetryWithBackoff(1, TimeSpan.FromMilliseconds(SettleDelayMilliseconds)).Subscribe( + var sub = source.RetryWithBackoff(1, TimeSpan.FromMilliseconds(RetryDelayMilliseconds)).Subscribe( static _ => { }, ex => caught = ex); sub.Dispose(); @@ -461,14 +463,14 @@ public async Task WhenScheduledSourceActionThrows_ThenForwardsError() public async Task WhenSubscribeSynchronousOmitsErrorAndCompletedCallbacks_ThenNullPathsTaken() { Subject subject = new(); - TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource processed = new(); using var sub = subject.SubscribeSynchronous(value => { _ = processed.TrySetResult(); return default; }); subject.OnNext(1); - await processed.Task.WaitAsync(GuardTimeout); + await processed.Task; // Subject silently terminates without invoking the optional callbacks. subject.OnError(new InvalidOperationException("ignored")); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs index 40edd587..5d0f1bdb 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs @@ -10,18 +10,6 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; [System.Diagnostics.DebuggerDisplay("ScanWithInitialTests: {nameof(ScanWithInitialTests),nq}")] public partial class ScanWithInitialTests { - /// Spin iterations used to widen the interleaving window in contention tests. - private const int InterleavingSpinIterations = 100; - -#if NET9_0_OR_GREATER - - /// Synchronization gate used by tests. - private readonly Lock _gate = new(); -#else - /// Synchronization gate used by tests. - private readonly object _gate = new(); -#endif - /// Tests that emits the initial value immediately upon subscription. /// A representing the asynchronous test operation. [Test] @@ -96,60 +84,48 @@ public async Task AccumulatorError_PropagatesError() await Assert.That(errors).IsCollectionEqualTo([exception]); } - /// Tests that is thread-safe. + /// Tests that a terminal notification raised from inside an emission completes the + /// sink exactly once and stops accumulating, which is the interleaving the sink's gate and + /// _done latch exist to serialize. /// A representing the asynchronous test operation. [Test] - [SuppressMessage( - "Concurrency", - "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = "Test is synchronous.")] - public async Task Observable_IsThreadSafe() + public async Task OnCompletedDuringEmission_CompletesOnceAndStopsAccumulating() { // Arrange Subject source = new(); const int Initial = 0; - Func accumulator = static (acc, x) => - { - Thread.SpinWait(InterleavingSpinIterations); - return acc + x; - }; + const int EmissionsBeforeCompletion = 3; + const int EmissionCount = 100; + Func accumulator = static (acc, x) => acc + x; ScanWithInitialObservable observable = new(source, Initial, accumulator); List results = []; var completedCount = 0; - const int ContendedEmissionCount = 100; - const int CompletionDelayMilliseconds = 50; // Act using (observable.Subscribe( x => { - lock (_gate) + results.Add(x); + if (results.Count == EmissionsBeforeCompletion) { - results.Add(x); + // Re-enter the sink with the terminal notification from inside its own emission. + source.OnCompleted(); } }, static _ => { }, - () => Interlocked.Increment(ref completedCount))) + () => completedCount++)) { - var t1 = Task.Run(() => - { - for (var i = 0; i < ContendedEmissionCount; i++) - { - source.OnNext(i); - } - }); - var t2 = Task.Run(async () => + for (var i = 0; i < EmissionCount; i++) { - await Task.Delay(CompletionDelayMilliseconds); - source.OnCompleted(); - }); - await Task.WhenAll(t1, t2); + source.OnNext(i); + } } // Assert - // We can't easily assert the exact sequence due to the non-thread-safe Subject, - // but we can assert that it didn't crash and the state remains consistent. - // The lock in ScanWithInitialSink ensures that OnNext doesn't race with OnCompleted internally. - await Assert.That(completedCount).IsEqualTo(1); + using (Assert.Multiple()) + { + await Assert.That(completedCount).IsEqualTo(1); + await Assert.That(results).Count().IsEqualTo(EmissionsBeforeCompletion); + } } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs index 81ae9fe1..e1539579 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs @@ -17,18 +17,12 @@ public class SelectAsyncConcurrentObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Settle delay in milliseconds used to let an awaited continuation attempt delivery. - private const int SettleDelayMilliseconds = 50; - /// Max concurrency used for two-in-flight tests. private const int MaxConcurrencyTwo = 2; /// Max concurrency used for four-in-flight tests. private const int MaxConcurrencyFour = 4; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that SelectAsyncConcurrent forwards selector exceptions. /// A representing the asynchronous test operation. [Test] @@ -36,14 +30,14 @@ public async Task WhenSelectAsyncConcurrentSelectorThrows_ThenForwardsError() { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource faulted = new(); InvalidOperationException expected = new(SelectorErrorMessage); using var sub = subject.SelectAsyncConcurrent(_ => Task.FromException(expected), MaxConcurrencyTwo) .Subscribe( static _ => { }, ex => faulted.TrySetResult(ex)); subject.OnNext(TriggerValue); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -69,21 +63,25 @@ public async Task WhenSelectAsyncConcurrentDisposedMidFlight_ThenSuppressesEmiss { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // The gate completes its continuations inline, so releasing it runs the selector's tail here. + TaskCompletionSource gate = new(); + TaskCompletionSource selectorResumed = new(); List results = []; var completed = false; var sub = subject.SelectAsyncConcurrent( async x => { await gate.Task.ConfigureAwait(false); + _ = selectorResumed.TrySetResult(true); return x; }, MaxConcurrencyTwo).Subscribe(results.Add, () => completed = true); subject.OnNext(TriggerValue); subject.OnCompleted(); sub.Dispose(); - _ = gate.TrySetResult(true); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + gate.SetResult(true); + await selectorResumed.Task; await Assert.That(results).IsEmpty(); await Assert.That(completed).IsFalse(); } @@ -96,9 +94,9 @@ public async Task WhenSelectAsyncConcurrentCompletesWithInFlight_ThenDeferredCom const int First = 1; const int Second = 2; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource gate = new(); List results = []; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(); using var sub = subject.SelectAsyncConcurrent( async x => { @@ -110,11 +108,11 @@ public async Task WhenSelectAsyncConcurrentCompletesWithInFlight_ThenDeferredCom subject.OnNext(Second); subject.OnCompleted(); - // The selector is gated; nothing should have emitted yet. - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + // Both selectors are parked on the gate, so nothing can have emitted or completed. await Assert.That(completed.Task.IsCompleted).IsFalse(); - _ = gate.TrySetResult(true); - var done = await completed.Task.WaitAsync(GuardTimeout); + await Assert.That(results).IsEmpty(); + gate.SetResult(true); + var done = await completed.Task; await Assert.That(done).IsTrue(); // Downstream OnNext from this operator is serialized inside the sink's lock, so the @@ -140,7 +138,6 @@ public async Task WhenEventsAfterCompleted_ThenDropped() source.Observer.OnNext(1); source.Observer.OnError(new InvalidOperationException("late")); source.Observer.OnCompleted(); - await Task.Delay(SettleDelayMilliseconds); await Assert.That(completedCount).IsEqualTo(1); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs index e664cd06..d6b1fa38 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs @@ -17,12 +17,6 @@ public class SelectAsyncSequentialObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Settle delay in milliseconds used to let an awaited continuation attempt delivery. - private const int SettleDelayMilliseconds = 50; - - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that SelectAsyncSequential forwards selector exceptions and stops draining the queue afterwards. /// A representing the asynchronous test operation. [Test] @@ -31,7 +25,7 @@ public async Task WhenSelectAsyncSequentialSelectorThrows_ThenForwardsErrorAndSt const int First = 1; const int Second = 2; Subject subject = new(); - TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource faulted = new(); List results = []; InvalidOperationException expected = new(SelectorErrorMessage); using var sub = subject @@ -39,7 +33,7 @@ public async Task WhenSelectAsyncSequentialSelectorThrows_ThenForwardsErrorAndSt .Subscribe(results.Add, ex => faulted.TrySetResult(ex)); subject.OnNext(First); subject.OnNext(Second); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); await Assert.That(results).IsEmpty(); } @@ -66,19 +60,23 @@ public async Task WhenSelectAsyncSequentialDisposedMidFlight_ThenSuppressesEmiss { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // The gate completes its continuations inline, so releasing it runs the selector's tail here. + TaskCompletionSource gate = new(); + TaskCompletionSource selectorResumed = new(); List results = []; var completed = false; var sub = subject.SelectAsyncSequential(async x => { await gate.Task.ConfigureAwait(false); + _ = selectorResumed.TrySetResult(true); return x; }).Subscribe(results.Add, () => completed = true); subject.OnNext(TriggerValue); subject.OnCompleted(); sub.Dispose(); - _ = gate.TrySetResult(true); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + gate.SetResult(true); + await selectorResumed.Task; await Assert.That(results).IsEmpty(); await Assert.That(completed).IsFalse(); } @@ -90,9 +88,9 @@ public async Task WhenSelectAsyncSequentialCompletesWhileProcessing_ThenDeferred { const int Value = 42; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource gate = new(); List results = []; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(); using var sub = subject.SelectAsyncSequential(async x => { await gate.Task.ConfigureAwait(false); @@ -101,11 +99,11 @@ public async Task WhenSelectAsyncSequentialCompletesWhileProcessing_ThenDeferred subject.OnNext(Value); subject.OnCompleted(); - // Completion must not fire while selector is gated. - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + // The selector is parked on the gate, so nothing can have emitted or completed. await Assert.That(completed.Task.IsCompleted).IsFalse(); - _ = gate.TrySetResult(true); - var done = await completed.Task.WaitAsync(GuardTimeout); + await Assert.That(results).IsEmpty(); + gate.SetResult(true); + var done = await completed.Task; await Assert.That(done).IsTrue(); await Assert.That(results).IsCollectionEqualTo([Value]); } @@ -126,7 +124,6 @@ public async Task WhenEventsAfterTerminated_ThenDropped() source.Observer.OnCompleted(); source.Observer.OnError(new InvalidOperationException("late")); source.Observer.OnCompleted(); - await Task.Delay(SettleDelayMilliseconds); await Assert.That(completedCount).IsEqualTo(1); await Assert.That(caught).IsNull(); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs index 8bb576dc..5f6c67db 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs @@ -17,21 +17,12 @@ public class SelectLatestAsyncObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Settle delay in milliseconds used to let an awaited continuation attempt delivery. - private const int SettleDelayMilliseconds = 50; - - /// Poll interval in milliseconds used while waiting for an emission. - private const int PollIntervalMilliseconds = 10; - /// Multiplier applied by the gated selector whose result is expected never to be delivered. private const int SuppressedProjectionMultiplier = 2; /// Multiplier applied inside the projection selector. private const int ProjectionMultiplier = 10; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that SelectLatestAsync forwards selector exceptions. /// A representing the asynchronous test operation. [Test] @@ -39,13 +30,13 @@ public async Task WhenSelectLatestAsyncSelectorThrows_ThenForwardsError() { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource faulted = new(); InvalidOperationException expected = new(SelectorErrorMessage); using var sub = subject.SelectLatestAsync(_ => Task.FromException(expected)).Subscribe( static _ => { }, ex => faulted.TrySetResult(ex)); subject.OnNext(TriggerValue); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -71,21 +62,23 @@ public async Task WhenSelectLatestAsyncDisposedMidFlight_ThenSuppressesEmissionA { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // The gate completes its continuations inline, so releasing it runs the selector's tail here. + TaskCompletionSource gate = new(); + TaskCompletionSource selectorResumed = new(); List results = []; var completed = false; var sub = subject.SelectLatestAsync(async x => { await gate.Task.ConfigureAwait(false); + _ = selectorResumed.TrySetResult(true); return x * SuppressedProjectionMultiplier; }).Subscribe(results.Add, () => completed = true); subject.OnNext(TriggerValue); subject.OnCompleted(); sub.Dispose(); - _ = gate.TrySetResult(true); - - // Give the awaited continuation a chance to attempt delivery. - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + gate.SetResult(true); + await selectorResumed.Task; await Assert.That(results).IsEmpty(); await Assert.That(completed).IsFalse(); } @@ -98,14 +91,18 @@ public async Task WhenSelectLatestAsyncNewerArrives_ThenOlderResultDropped() const int Slow = 1; const int Fast = 2; Subject subject = new(); - TaskCompletionSource slowGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // The gate completes its continuations inline, so releasing it runs the stale projection's tail here. + TaskCompletionSource slowGate = new(); + TaskCompletionSource slowResumed = new(); List results = []; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(); using var sub = subject.SelectLatestAsync(async x => { if (x == Slow) { await slowGate.Task.ConfigureAwait(false); + _ = slowResumed.TrySetResult(true); } return x * ProjectionMultiplier; @@ -113,15 +110,12 @@ public async Task WhenSelectLatestAsyncNewerArrives_ThenOlderResultDropped() subject.OnNext(Slow); subject.OnNext(Fast); - // Wait for the fast projection to complete and emit. - while (results.Count == 0) - { - await Task.Delay(PollIntervalMilliseconds).ConfigureAwait(false); - } - - _ = slowGate.TrySetResult(true); + // The Fast projection is ungated, so its result is already delivered. + await Assert.That(results).IsCollectionEqualTo([Fast * ProjectionMultiplier]); + slowGate.SetResult(true); + await slowResumed.Task; subject.OnCompleted(); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; // Only the latest (Fast) projection's result should appear. await Assert.That(results).IsCollectionEqualTo([Fast * ProjectionMultiplier]); @@ -133,12 +127,12 @@ public async Task WhenSelectLatestAsyncNewerArrives_ThenOlderResultDropped() public async Task WhenSelectLatestAsyncSourceCompletesWithNoValues_ThenForwardsCompletion() { Subject subject = new(); - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(); using var sub = subject.SelectLatestAsync(Task.FromResult).Subscribe( static _ => { }, () => completed.TrySetResult(true)); subject.OnCompleted(); - var done = await completed.Task.WaitAsync(GuardTimeout); + var done = await completed.Task; await Assert.That(done).IsTrue(); } @@ -158,7 +152,6 @@ public async Task WhenEventsAfterCompleted_ThenDropped() source.Observer.OnNext(1); source.Observer.OnError(new InvalidOperationException("late")); source.Observer.OnCompleted(); - await Task.Delay(SettleDelayMilliseconds); await Assert.That(completedCount).IsLessThanOrEqualTo(1); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs index e09fcf02..9b269275 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs @@ -17,12 +17,6 @@ public class SubscribeAsyncObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Settle delay in milliseconds used to confirm completion is deferred. - private const int SettleDelayMilliseconds = 50; - - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that values are handled in order and completion fires. /// A representing the asynchronous test operation. [Test] @@ -32,7 +26,7 @@ public async Task WhenSubscribeAsyncProcessesValues_ThenInOrder() const int Second = 2; Subject subject = new(); List results = []; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(); using var sub = subject.SubscribeSynchronous( x => { @@ -44,7 +38,7 @@ public async Task WhenSubscribeAsyncProcessesValues_ThenInOrder() subject.OnNext(First); subject.OnNext(Second); subject.OnCompleted(); - var done = await completed.Task.WaitAsync(GuardTimeout); + var done = await completed.Task; await Assert.That(done).IsTrue(); await Assert.That(results).IsCollectionEqualTo([First, Second]); } @@ -56,12 +50,12 @@ public async Task WhenSubscribeAsyncHandlerThrows_ThenForwardsToOnError() { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource faulted = new(); InvalidOperationException expected = new(HandlerFailedMessage); using var sub = subject.SubscribeSynchronous(_ => ValueTask.FromException(expected), ex => faulted.TrySetResult(ex)); subject.OnNext(TriggerValue); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -102,17 +96,20 @@ public async Task WhenSubscribeAsyncCompletesWhileProcessing_ThenDeferredComplet { const int Value = 7; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // The gate completes its continuations inline, so releasing it drains the pump before control returns. + TaskCompletionSource gate = new(); + TaskCompletionSource completed = new(); using var sub = subject.SubscribeSynchronous( async _ => await gate.Task.ConfigureAwait(false), () => completed.TrySetResult(true)); subject.OnNext(Value); subject.OnCompleted(); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + // The handler is parked on the gate, so completion cannot have been signalled yet. await Assert.That(completed.Task.IsCompleted).IsFalse(); - _ = gate.TrySetResult(true); - var done = await completed.Task.WaitAsync(GuardTimeout); + gate.SetResult(true); + var done = await completed.Task; await Assert.That(done).IsTrue(); } @@ -123,8 +120,8 @@ public async Task WhenSubscribeAsyncCompletesWhileProcessingWithoutCallback_Then { const int Value = 7; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource handled = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource gate = new(); + TaskCompletionSource handled = new(); using var sub = subject.SubscribeSynchronous(async value => { await gate.Task.ConfigureAwait(false); @@ -132,9 +129,10 @@ public async Task WhenSubscribeAsyncCompletesWhileProcessingWithoutCallback_Then }); subject.OnNext(Value); subject.OnCompleted(); - _ = gate.TrySetResult(true); - var done = await handled.Task.WaitAsync(GuardTimeout); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + // Releasing the gate resumes the handler inline, so the null-completion path runs here. + gate.SetResult(true); + var done = await handled.Task; await Assert.That(done).IsTrue(); } @@ -145,8 +143,8 @@ public async Task WhenSubscribeAsyncDisposedDuringInFlight_ThenSuppressesComplet { const int Value = 7; Subject subject = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource handlerStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource gate = new(); + TaskCompletionSource handlerStarted = new(); Exception? caught = null; var completedCount = 0; var sub = subject.SubscribeSynchronous( @@ -159,11 +157,12 @@ public async Task WhenSubscribeAsyncDisposedDuringInFlight_ThenSuppressesComplet ex => caught = ex, () => completedCount++); subject.OnNext(Value); - await handlerStarted.Task.WaitAsync(GuardTimeout); + await handlerStarted.Task; subject.OnCompleted(); sub.Dispose(); - _ = gate.TrySetResult(true); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + + // Releasing the gate lets the handler throw inline, so the suppressed terminal paths run here. + gate.SetResult(true); await Assert.That(caught).IsNull(); await Assert.That(completedCount).IsEqualTo(0); } @@ -208,7 +207,6 @@ public async Task WhenEventsAfterCompleted_ThenDropped() source.Observer.OnNext(1); source.Observer.OnError(new InvalidOperationException("late")); source.Observer.OnCompleted(); - await Task.Delay(SettleDelayMilliseconds); await Assert.That(completedCount).IsEqualTo(1); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); @@ -226,7 +224,6 @@ public async Task WhenOnCompletedAfterError_ThenDropped() using var sub = source.SubscribeSynchronous(static _ => default, ex => caught = ex, () => completedCount++); source.Observer.OnError(expected); source.Observer.OnCompleted(); - await Task.Delay(SettleDelayMilliseconds); await Assert.That(caught).IsSameReferenceAs(expected); await Assert.That(completedCount).IsEqualTo(0); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs index 32d0bc2b..376fd170 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs @@ -8,9 +8,6 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; /// on the sink that only fire when the upstream pushes events past its own completion. public class SynchronizeAsyncObservableTests { - /// Settle delay to confirm nothing fires. - private const int SettleDelayMilliseconds = 50; - /// Verifies that OnNext, OnError and a duplicate OnCompleted /// arriving after the source has already completed are silently dropped. /// A representing the asynchronous test operation. @@ -27,7 +24,6 @@ public async Task WhenEventsAfterCompleted_ThenDropped() source.Observer.OnNext(1); source.Observer.OnError(new InvalidOperationException("late")); source.Observer.OnCompleted(); - await Task.Delay(SettleDelayMilliseconds); await Assert.That(completedCount).IsEqualTo(1); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); @@ -66,7 +62,6 @@ public async Task WhenSyncSignalDisposedTwice_ThenSecondDisposeIsNoOp() processed++; }); source.Observer.OnNext(1); - await Task.Delay(SettleDelayMilliseconds); await Assert.That(processed).IsEqualTo(1); } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs index 24507290..04722e09 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs @@ -2,8 +2,9 @@ // 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.Concurrent; using System.Reactive.Subjects; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; @@ -15,20 +16,14 @@ public class ThrottleUntilTrueObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Throttle window in milliseconds for tests. - private const int ThrottleWindowMilliseconds = 50; + /// Throttle window in virtual ticks. + private const int ThrottleWindowTicks = 50; - /// Long throttle window in milliseconds used by the dispose-before-fire test. - private const int LongThrottleWindowMilliseconds = 500; - - /// Settle delay in milliseconds used to confirm a throttled emission never fires. - private const int SettleDelayMilliseconds = 150; + /// Virtual ticks to advance to take the clock one tick past the throttle window. + private const int AdvancePastWindowTicks = ThrottleWindowTicks + 1; /// Throttle window for tests. - private static readonly TimeSpan ThrottleWindow = TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds); - - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ThrottleWindow = TimeSpan.FromTicks(ThrottleWindowTicks); /// Verifies that elements matching the predicate emit immediately. /// A representing the asynchronous test operation. @@ -36,28 +31,28 @@ public class ThrottleUntilTrueObservableTests public async Task WhenThrottleUntilTruePredicateTrue_ThenEmitsImmediately() { const int MatchingValue = 1; + VirtualClock scheduler = new(); Subject subject = new(); - TaskCompletionSource emitted = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static x => x == MatchingValue) - .Subscribe(v => emitted.TrySetResult(v)); + List emitted = []; + using var sub = Throttled(subject, scheduler, static x => x == MatchingValue).Subscribe(emitted.Add); subject.OnNext(MatchingValue); - var got = await emitted.Task.WaitAsync(GuardTimeout); - await Assert.That(got).IsEqualTo(MatchingValue); + await Assert.That(emitted).IsCollectionEqualTo([MatchingValue]); } - /// Verifies that non-matching elements are throttled but eventually emit. + /// Verifies that non-matching elements are held until the throttle window elapses. /// A representing the asynchronous test operation. [Test] - public async Task WhenThrottleUntilTruePredicateFalse_ThenEmitsAfterDelay() + public async Task WhenThrottleUntilTruePredicateFalse_ThenEmitsAfterWindow() { const int NonMatchingValue = 99; + VirtualClock scheduler = new(); Subject subject = new(); - TaskCompletionSource emitted = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => false) - .Subscribe(v => emitted.TrySetResult(v)); + List emitted = []; + using var sub = Throttled(subject, scheduler, static _ => false).Subscribe(emitted.Add); subject.OnNext(NonMatchingValue); - var got = await emitted.Task.WaitAsync(GuardTimeout); - await Assert.That(got).IsEqualTo(NonMatchingValue); + await Assert.That(emitted).IsEmpty(); + scheduler.AdvanceBy(AdvancePastWindowTicks); + await Assert.That(emitted).IsCollectionEqualTo([NonMatchingValue]); } /// Verifies that a later throttled value replaces an earlier still-pending one. @@ -67,24 +62,14 @@ public async Task WhenThrottleUntilTrueFastReplacements_ThenLatestWins() { const int Earlier = 1; const int Later = 2; + VirtualClock scheduler = new(); Subject subject = new(); - ConcurrentQueue emissions = new(); - TaskCompletionSource laterArrived = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => false).Subscribe(v => - { - emissions.Enqueue(v); - _ = v == Later && laterArrived.TrySetResult(); - }); + List emissions = []; + using var sub = Throttled(subject, scheduler, static _ => false).Subscribe(emissions.Add); subject.OnNext(Earlier); subject.OnNext(Later); - - // Later's timer is never superseded, so it always fires; wait on that deterministically - // rather than racing the wall-clock window. Under scheduling pressure Earlier's timer may - // still slip through first, so assert the invariant the operator guarantees: whatever the - // intermediate emissions, the final value observed is the latest one. - await laterArrived.Task.WaitAsync(GuardTimeout); - var observed = emissions.ToArray(); - await Assert.That(observed[^1]).IsEqualTo(Later); + scheduler.AdvanceBy(AdvancePastWindowTicks); + await Assert.That(emissions).IsCollectionEqualTo([Later]); } /// Verifies that source errors are forwarded. @@ -92,10 +77,11 @@ public async Task WhenThrottleUntilTrueFastReplacements_ThenLatestWins() [Test] public async Task WhenThrottleUntilTrueSourceErrors_ThenForwardsError() { + VirtualClock scheduler = new(); Subject subject = new(); Exception? caught = null; InvalidOperationException expected = new(SourceErrorMessage); - using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => true).Subscribe( + using var sub = Throttled(subject, scheduler, static _ => true).Subscribe( static _ => { }, ex => caught = ex); subject.OnError(expected); @@ -108,9 +94,10 @@ public async Task WhenThrottleUntilTrueSourceErrors_ThenForwardsError() public async Task WhenThrottleUntilTrueSourceCompletes_ThenForwardsCompletion() { const int IgnoredAfterCompletion = 9; + VirtualClock scheduler = new(); Subject subject = new(); var completed = false; - using var sub = subject.ThrottleUntilTrue(ThrottleWindow, static _ => true).Subscribe( + using var sub = Throttled(subject, scheduler, static _ => true).Subscribe( static _ => { }, () => completed = true); subject.OnCompleted(); @@ -124,16 +111,15 @@ public async Task WhenThrottleUntilTrueSourceCompletes_ThenForwardsCompletion() public async Task WhenThrottleUntilTrueDisposedBeforeFire_ThenNoEmission() { const int NonMatchingValue = 1; + VirtualClock scheduler = new(); Subject subject = new(); List results = []; - var sub = subject - .ThrottleUntilTrue(TimeSpan.FromMilliseconds(LongThrottleWindowMilliseconds), static _ => false) - .Subscribe(results.Add); + var sub = Throttled(subject, scheduler, static _ => false).Subscribe(results.Add); subject.OnNext(NonMatchingValue); sub.Dispose(); - // Wait past the throttle window to confirm nothing fires. - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); + // Moving past the throttle window confirms the cancelled timer never fires. + scheduler.AdvanceBy(AdvancePastWindowTicks); await Assert.That(results).IsEmpty(); } @@ -143,11 +129,12 @@ public async Task WhenThrottleUntilTrueDisposedBeforeFire_ThenNoEmission() [Test] public async Task WhenEventsAfterCompleted_ThenDropped() { + VirtualClock scheduler = new(); SyncDirectSource source = new(); List values = []; Exception? caught = null; var completedCount = 0; - using var sub = source.ThrottleUntilTrue(ThrottleWindow, static _ => true) + using var sub = Throttled(source, scheduler, static _ => true) .Subscribe(values.Add, ex => caught = ex, () => completedCount++); source.Observer.OnCompleted(); source.Observer.OnNext(1); @@ -157,4 +144,15 @@ public async Task WhenEventsAfterCompleted_ThenDropped() await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); } + + /// Builds the operator over a clock the test advances, so no emission depends on wall time. + /// The source sequence. + /// The virtual clock timing throttled emissions. + /// The bypass predicate. + /// The throttled sequence. + private static IObservable Throttled( + IObservable source, + VirtualClock scheduler, + Func predicate) => + new ThrottleUntilTrueObservable(source, ThrottleWindow, predicate, scheduler); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs index 9184b3ff..561a3fb2 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs @@ -20,15 +20,6 @@ public class WhileObservableTests /// Number of inline iterations to run. private const int IterationCount = 3; - /// Settle delay in milliseconds used to confirm a disposed loop stops ticking. - private const int SettleDelayMilliseconds = 50; - - /// Maximum tolerated extra iterations after Dispose() returns. - private const int MaxStragglerIterations = 10; - - /// Longest a test waits for an asynchronous signal before failing. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that the inline form runs until the predicate returns false. /// A representing the asynchronous test operation. [Test] @@ -52,14 +43,18 @@ public async Task WhenWhileInline_ThenRunsUntilPredicateFalseAndCompletes() public async Task WhenWhileWithScheduler_ThenRunsUntilPredicateFalse() { var remaining = IterationCount; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); var emitted = 0; + var completed = false; + ManualSequencer sequencer = new(); - using var sub = ReactiveExtensions.While(() => remaining > 0, () => remaining--, TaskPoolSequencer.Default) - .Subscribe(_ => emitted++, () => completed.TrySetResult(emitted)); + using var sub = ReactiveExtensions.While(() => remaining > 0, () => remaining--, sequencer) + .Subscribe(_ => emitted++, () => completed = true); - var final = await completed.Task.WaitAsync(GuardTimeout); - await Assert.That(final).IsEqualTo(IterationCount); + // Nothing runs until the queued work is drained, proving every iteration went through the scheduler. + await Assert.That(emitted).IsEqualTo(0); + sequencer.RunAll(); + await Assert.That(emitted).IsEqualTo(IterationCount); + await Assert.That(completed).IsTrue(); } /// Verifies that an exception thrown by the predicate is forwarded. @@ -96,36 +91,54 @@ public async Task WhenWhileActionThrows_ThenForwardsError() public async Task WhenWhileScheduledThenDisposed_ThenIterationStops() { var ran = 0; - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + ManualSequencer sequencer = new(); - var sub = ReactiveExtensions.While( - static () => true, - () => SignalFirstIteration(ref ran, gate), - TaskPoolSequencer.Default) + var sub = ReactiveExtensions.While(static () => true, () => ran++, sequencer) .Subscribe(static _ => { }); - await gate.Task.WaitAsync(GuardTimeout); - sub.Dispose(); - - var snapshot = Volatile.Read(ref ran); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); - var later = Volatile.Read(ref ran); + // One iteration, which arms the next one. + sequencer.RunNext(); + await Assert.That(ran).IsEqualTo(1); - // The loop may execute a few more iterations between Dispose() being called - // and the next disposal-check, but it must not keep ticking forever. - await Assert.That(later - snapshot).IsLessThanOrEqualTo(MaxStragglerIterations); + sub.Dispose(); + sequencer.RunAll(); + await Assert.That(ran).IsEqualTo(1); } - /// Increments and signals on the first iteration. - /// Shared iteration counter. - /// Completion source signalled after the first iteration. - private static void SignalFirstIteration(ref int counter, TaskCompletionSource gate) + /// A sequencer that queues every work item so the test decides when each iteration runs. + private sealed class ManualSequencer : ISequencer { - if (Interlocked.Increment(ref counter) != 1) + /// Work items scheduled and not yet run. + private readonly Queue _pending = new(); + + /// Gets the sequencer's notion of current time, which never moves. + public DateTimeOffset Now => DateTimeOffset.UnixEpoch; + + /// Gets the sequencer's monotonic timestamp, which never moves. + public long Timestamp => 0; + + /// + public void Schedule(IWorkItem item) => _pending.Enqueue(item); + + /// + public void Schedule(IWorkItem item, long dueTimestamp) => Schedule(item); + + /// Runs the oldest queued work item, if any. + internal void RunNext() { - return; + if (_pending.Count > 0) + { + _pending.Dequeue().Execute(); + } } - _ = gate.TrySetResult(true); + /// Drains the queue, including work items queued by the items it runs. + internal void RunAll() + { + while (_pending.Count > 0) + { + _pending.Dequeue().Execute(); + } + } } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs index e2b4753f..c1c28aea 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs @@ -234,7 +234,6 @@ public async Task AsyncAndSequentialHelpersEmitExpectedValues() const int SequentialMultiplier = 2; const int ConcurrentMultiplier = 3; const int MaxConcurrency = 2; - const int DelayMilliseconds = 50; const int SequentialResult = 4; const int ConcurrentResult = 6; Subject source = new(); @@ -245,8 +244,8 @@ public async Task AsyncAndSequentialHelpersEmitExpectedValues() using var conSub = source .SelectAsyncConcurrent(static x => Task.FromResult(x * ConcurrentMultiplier), MaxConcurrency) .Subscribe(concurrent.Add); + // Both selectors return an already-completed task, so their projections land inside OnNext. source.OnNext(InputValue); - await Task.Delay(DelayMilliseconds); List runAll = []; using var runAllSub = new[] { Observable.Return(RxVoid.Default), Observable.Return(RxVoid.Default) }.RunAll() .Subscribe(runAll.Add); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs index 5e276b92..722bd928 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs @@ -19,9 +19,6 @@ public partial class ReactiveExtensionsTests /// String literal "initial" used by multiple tests. private const string InitialValueLiteral = "initial"; - /// Stabilization window for scheduler-driven assertions. - private const int SchedulerStabilizeMilliseconds = 100; - /// Value at which the TakeUntil/WaitUntil predicates trip. private const int PredicateThreshold = 5; @@ -42,7 +39,9 @@ public partial class ReactiveExtensionsTests [Test] public async Task SyncronizeAsync_RunsWithAsyncTasksInSubscriptions() { - // Given, When + // Given, When. The six handlers can run concurrently, so the counters use Interlocked; + // awaiting WhenAll is what establishes that every handler ran, because each one bumps + // itterations in its finally before its task completes. var result = 0; var itterations = 0; Subject subject = new(); @@ -53,16 +52,8 @@ async Task HandleAsync((bool Value, IDisposable Sync) x) { try { - if (x.Value) - { - await Task.Delay(LongDelayMilliseconds); - _ = Interlocked.Increment(ref result); - } - else - { - await Task.Delay(ShortDelayMilliseconds); - _ = Interlocked.Decrement(ref result); - } + await Task.Yield(); + _ = x.Value ? Interlocked.Increment(ref result) : Interlocked.Decrement(ref result); } finally { @@ -78,13 +69,13 @@ async Task HandleAsync((bool Value, IDisposable Sync) x) subject.OnNext(true); subject.OnNext(false); await Task.WhenAll(tasks); - while (itterations < SampleValue6) - { - _ = Thread.Yield(); - } // Then - await Assert.That(result).IsZero(); + using (Assert.Multiple()) + { + await Assert.That(Volatile.Read(ref result)).IsZero(); + await Assert.That(Volatile.Read(ref itterations)).IsEqualTo(SampleValue6); + } } /// Tests OnNext with params. @@ -490,9 +481,9 @@ public async Task WhenSubscribeAsyncWithAllHandlers_ThenInvokesAll() }); subject.OnNext(1); subject.OnNext(SampleValue2); - await allReceived.Task.WaitAsync(WaitTimeout); + await allReceived.Task; subject.OnCompleted(); - await completionSource.Task.WaitAsync(WaitTimeout); + await completionSource.Task; using (Assert.Multiple()) { await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); @@ -592,9 +583,10 @@ public async Task WhenOnErrorRetryNonMatchingExceptionType_ThenOnErrorCallbackSk observer.OnError(failure); return EmptyDisposable.Instance; }); - using var sub = source.OnErrorRetry(caught.Add, 1, TimeSpan.Zero, Sequencer.Default) + VirtualClock scheduler = new(); + using var sub = source.OnErrorRetry(caught.Add, 1, TimeSpan.Zero, scheduler) .Subscribe(values.Add, static _ => { }); - await Task.Delay(TimeSpan.FromMilliseconds(SchedulerStabilizeMilliseconds)); + scheduler.AdvanceBy(1); await Assert.That(caught).IsEmpty(); await Assert.That(values.Count).IsGreaterThanOrEqualTo(1); } @@ -626,7 +618,7 @@ public async Task WhenRetryWithBackoffTwoArgOverload_ThenRetriesUntilSuccess() const int MaxRetries = 3; using var sub = source.RetryWithBackoff(MaxRetries, TimeSpan.FromMilliseconds(1)) .Subscribe(values.Add, () => done.TrySetResult(values)); - var captured = await done.Task.WaitAsync(WaitTimeout); + var captured = await done.Task; await Assert.That(captured).IsCollectionEqualTo([SuccessAttempt]); } @@ -649,7 +641,7 @@ public async Task WhenBufferUntilInactiveTwoArgOverload_ThenFlushesBufferOnCompl subject.OnNext(1); subject.OnNext(SampleValue2); subject.OnCompleted(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(results.Count).IsGreaterThanOrEqualTo(1); await Assert.That(results[^1]).IsCollectionEqualTo([1, SampleValue2]); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs index d913cb55..028728a6 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs @@ -5,7 +5,6 @@ using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; -using ReactiveUI.Primitives.Async.Tests; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Disposables; @@ -467,10 +466,15 @@ public async Task WhenSyncTimerCalledWithoutScheduler_ThenProducesTicks() { const int TickPeriodMilliseconds = 50; List results = []; + TaskCompletionSource firstTick = new(); using var sub = ReactiveExtensions.SyncTimer(TimeSpan.FromMilliseconds(TickPeriodMilliseconds)) .Take(SampleValue2) - .Subscribe(results.Add); - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + .Subscribe(tick => + { + results.Add(tick); + _ = firstTick.TrySetResult(); + }); + await firstTick.Task; await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); } @@ -484,7 +488,7 @@ public async Task WhenStartActionWithNullScheduler_ThenExecutesAction() using var sub = ReactiveExtensions.Start(() => executed = true, null).Subscribe( static _ => { }, completed.SetResult); - await completed.Task.WaitAsync(LongWaitTimeout); + await completed.Task; await Assert.That(executed).IsTrue(); } @@ -497,7 +501,7 @@ public async Task WhenStartFuncWithNullScheduler_ThenReturnsResult() await Assert.That(result).IsEqualTo(SampleValue42); } - /// Tests ScheduleSafe with TimeSpan and null scheduler uses Thread.Sleep path. + /// Tests ScheduleSafe with a TimeSpan and a null scheduler takes the inline blocking path. /// A representing the asynchronous test operation. [Test] public async Task WhenScheduleSafeWithTimeSpanAndNullScheduler_ThenSleepsAndExecutes() @@ -679,7 +683,7 @@ public async Task WhenUsingWithActionAndNullScheduler_ThenDisposesObject() using var sub = stream.Using(_ => executed = true, null).Subscribe( static _ => { }, completed.SetResult); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(executed).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs index 656efef9..31d41428 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs @@ -21,7 +21,7 @@ public async Task WhenSelectAsyncWithCancellationToken_ThenProjectsValues() results.Add, () => tcs.TrySetResult(true)); - await tcs.Task.WaitAsync(WaitTimeout); + await tcs.Task; await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); } @@ -40,7 +40,7 @@ public async Task WhenSelectAsyncSimple_ThenProjectsValues() results.Add, () => tcs.TrySetResult(true)); - await tcs.Task.WaitAsync(WaitTimeout); + await tcs.Task; await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); } @@ -59,7 +59,7 @@ public async Task WhenSelectAsyncSequential_ThenProcessesInOrder() results.Add, () => tcs.TrySetResult(true)); - await tcs.Task.WaitAsync(WaitTimeout); + await tcs.Task; await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); } @@ -69,20 +69,19 @@ public async Task WhenSelectAsyncSequential_ThenProcessesInOrder() [Test] public async Task WhenSelectLatestAsync_ThenEmitsLatestResult() { - const int AsyncDelayMs = 10; var source = ExpectedSequence123.ToObservable(); List results = []; TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); _ = source.SelectLatestAsync(static async x => { - await Task.Delay(AsyncDelayMs); + await Task.Yield(); return x * SampleValue2; }).Subscribe( results.Add, () => tcs.TrySetResult(true)); - await tcs.Task.WaitAsync(WaitTimeout); + await tcs.Task; // Switch means only the latest survives; with sources 1,2,3 and selector x*2, expect [6]. await Assert.That(results).IsNotEmpty(); @@ -101,12 +100,12 @@ public async Task WhenSelectAsyncConcurrent_ThenProcessesConcurrently() _ = source.SelectAsyncConcurrent( static async x => { - await Task.Delay(1); + await Task.Yield(); return x * SampleValue2; }, MaxConcurrency).Subscribe(results.Add, () => tcs.TrySetResult(true)); - await tcs.Task.WaitAsync(WaitTimeout); + await tcs.Task; results.Sort(); await Assert.That(results).IsCollectionEqualTo([SampleValue2, SampleValue4, SampleValue6]); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs index 4f3c3d71..43b6f892 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs @@ -17,25 +17,18 @@ public partial class ReactiveExtensionsTests [Test] public async Task SubscribeSynchronus_RunsWithAsyncTasksInSubscriptions() { - // Given, When. SubscribeSynchronous dispatches each OnNext concurrently on the thread - // pool, so result / itterations need Interlocked for the read-modify-write to be safe. + // Given, When. SubscribeSynchronous queues each OnNext and drains the queue one handler at + // a time; each handler resumes on a pool thread, so the counters use Interlocked. The + // alternating +1 / -1 handlers cancel out once all six have run. var result = 0; var itterations = 0; Subject subject = new(); + TaskCompletionSource allHandled = new(); using var disposable = subject.SubscribeSynchronous(async x => { - if (x) - { - await Task.Delay(LongDelayMilliseconds); - _ = Interlocked.Increment(ref result); - } - else - { - await Task.Delay(ShortDelayMilliseconds); - _ = Interlocked.Decrement(ref result); - } - - _ = Interlocked.Increment(ref itterations); + await Task.Yield(); + _ = x ? Interlocked.Increment(ref result) : Interlocked.Decrement(ref result); + _ = Interlocked.Increment(ref itterations) == SampleValue6 && allHandled.TrySetResult(); }); subject.OnNext(true); subject.OnNext(false); @@ -43,10 +36,7 @@ public async Task SubscribeSynchronus_RunsWithAsyncTasksInSubscriptions() subject.OnNext(false); subject.OnNext(true); subject.OnNext(false); - while (Volatile.Read(ref itterations) < SampleValue6) - { - _ = Thread.Yield(); - } + await allHandled.Task; // Then await Assert.That(Volatile.Read(ref result)).IsZero(); @@ -58,8 +48,8 @@ public async Task SubscribeSynchronus_RunsWithAsyncTasksInSubscriptions() public async Task SynchronizeSynchronous_RunsWithAsyncTasksInSubscriptions() { // Given, When. SynchronizeSynchronous dispatches each OnNext through an independent - // Continuation so the six HandleAsync invocations run concurrently on the thread pool — - // the int read-modify-write therefore needs Interlocked. The test asserts pair-wise + // Continuation so the six HandleAsync invocations can run concurrently — the int + // read-modify-write therefore needs Interlocked. The test asserts pair-wise // (+1, -1) sums to zero after WhenAll completes. var result = 0; var itterations = 0; @@ -71,16 +61,8 @@ async Task HandleAsync((bool Value, IDisposable Sync) x) { try { - if (x.Value) - { - await Task.Delay(LongDelayMilliseconds); - _ = Interlocked.Increment(ref result); - } - else - { - await Task.Delay(ShortDelayMilliseconds); - _ = Interlocked.Decrement(ref result); - } + await Task.Yield(); + _ = x.Value ? Interlocked.Increment(ref result) : Interlocked.Decrement(ref result); } finally { @@ -98,7 +80,11 @@ async Task HandleAsync((bool Value, IDisposable Sync) x) await Task.WhenAll(tasks); // Then - await Assert.That(Volatile.Read(ref result)).IsZero(); + using (Assert.Multiple()) + { + await Assert.That(Volatile.Read(ref result)).IsZero(); + await Assert.That(Volatile.Read(ref itterations)).IsEqualTo(SampleValue6); + } } /// Syncronizes the asynchronous runs with asynchronous tasks in subscriptions. @@ -106,25 +92,18 @@ async Task HandleAsync((bool Value, IDisposable Sync) x) [Test] public async Task SubscribeAsync_RunsWithAsyncTasksInSubscriptions() { - // Given, When. SubscribeAsync dispatches each OnNext concurrently, so the integer - // read-modify-write needs Interlocked and the polling read needs Volatile. + // Given, When. SubscribeAsync queues each OnNext and drains the queue one handler at a + // time; each handler resumes on a pool thread, so the counters use Interlocked. The + // alternating +1 / -1 handlers cancel out once all six have run. var result = 0; var itterations = 0; Subject subject = new(); + TaskCompletionSource allHandled = new(); using var disposable = subject.SubscribeAsync(async x => { - if (x) - { - await Task.Delay(LongDelayMilliseconds); - _ = Interlocked.Increment(ref result); - } - else - { - await Task.Delay(ShortDelayMilliseconds); - _ = Interlocked.Decrement(ref result); - } - - _ = Interlocked.Increment(ref itterations); + await Task.Yield(); + _ = x ? Interlocked.Increment(ref result) : Interlocked.Decrement(ref result); + _ = Interlocked.Increment(ref itterations) == SampleValue6 && allHandled.TrySetResult(); }); subject.OnNext(true); subject.OnNext(false); @@ -132,10 +111,7 @@ public async Task SubscribeAsync_RunsWithAsyncTasksInSubscriptions() subject.OnNext(false); subject.OnNext(true); subject.OnNext(false); - while (Volatile.Read(ref itterations) < SampleValue6) - { - _ = Thread.Yield(); - } + await allHandled.Task; // Then await Assert.That(Volatile.Read(ref result)).IsZero(); @@ -147,38 +123,41 @@ public async Task SubscribeAsync_RunsWithAsyncTasksInSubscriptions() public async Task WithLimitedConcurrency_LimitsConcurrentTasks() { const int MaxConcurrency = 3; + var inFlight = 0; var maxConcurrent = 0; - var currentConcurrent = 0; + Queue> pulled = new(); + List results = []; + var completed = false; + // Each task only finishes when the test completes its gate, so the pull count the limiter + // holds open is observable exactly rather than sampled while real tasks overlap. IEnumerable> CreateTasks() { for (var i = 1; i <= SampleValue10; i++) { - var value = i; - yield return Task.Run(async () => - { - lock (_gate) - { - currentConcurrent++; - maxConcurrent = Math.Max(maxConcurrent, currentConcurrent); - } - - await Task.Delay(SampleValue10); - lock (_gate) - { - currentConcurrent--; - } - - return value; - }); + TaskCompletionSource gate = new(); + pulled.Enqueue(gate); + inFlight++; + maxConcurrent = Math.Max(maxConcurrent, inFlight); + yield return gate.Task; } } - var results = await CreateTasks().WithLimitedConcurrency(MaxConcurrency).ToList(); + using var sub = CreateTasks().WithLimitedConcurrency(MaxConcurrency) + .Subscribe(results.Add, () => completed = true); + var next = 0; + while (pulled.Count > 0) + { + var gate = pulled.Dequeue(); + inFlight--; + gate.SetResult(++next); + } + using (Assert.Multiple()) { await Assert.That(results).Count().IsEqualTo(SampleValue10); await Assert.That(maxConcurrent).IsLessThanOrEqualTo(MaxConcurrency); + await Assert.That(completed).IsTrue(); } } @@ -228,7 +207,9 @@ IEnumerable> FaultingTasks() [Test] public async Task WithLimitedConcurrency_DisposeBeforeTaskContinuation_DropsWork() { - TaskCompletionSource task = new(TaskCreationOptions.RunContinuationsAsynchronously); + // The limiter attaches its continuation with ExecuteSynchronously, so SetResult runs the + // dropped-work path inline before it returns. + TaskCompletionSource task = new(); List values = []; Exception? caught = null; var completed = false; @@ -236,7 +217,6 @@ public async Task WithLimitedConcurrency_DisposeBeforeTaskContinuation_DropsWork .Subscribe(values.Add, ex => caught = ex, () => completed = true); sub.Dispose(); task.SetResult(SampleValue10); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); await Assert.That(completed).IsFalse(); @@ -269,7 +249,6 @@ public async Task WithLimitedConcurrency_NullTaskEntry_ThenNoNotifications() var completed = false; using var sub = tasks.WithLimitedConcurrency(1) .Subscribe(values.Add, ex => caught = ex, () => completed = true); - await Task.Delay(SettleDelayMilliseconds).ConfigureAwait(false); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); await Assert.That(completed).IsFalse(); @@ -337,7 +316,7 @@ public async Task SubscribeAsync_WithOnNextAndOnError_HandlesError() caughtException = ex; _ = errorSource.TrySetResult(true); }); - await errorSource.Task.WaitAsync(WaitTimeout); + await errorSource.Task; using (Assert.Multiple()) { await Assert.That(results).IsCollectionEqualTo([1]); @@ -365,7 +344,7 @@ public async Task SubscribeSynchronous_WithFullCallbacks_ExecutesAll() subject.OnNext(1); subject.OnNext(SampleValue2); subject.OnCompleted(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; using (Assert.Multiple()) { await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); @@ -392,9 +371,9 @@ public async Task SubscribeSynchronous_WithOnNextAndOnError_HandlesError() }, _ => errorHandled.TrySetResult()); subject.OnNext(1); - await onNextCompleted.Task.WaitAsync(WaitTimeout); + await onNextCompleted.Task; subject.OnError(new InvalidOperationException()); - await errorHandled.Task.WaitAsync(WaitTimeout); + await errorHandled.Task; using (Assert.Multiple()) { await Assert.That(results).IsCollectionEqualTo([1]); @@ -420,7 +399,7 @@ public async Task SubscribeSynchronous_WithOnNextAndOnCompleted_CompletesCorrect subject.OnNext(1); subject.OnNext(SampleValue2); subject.OnCompleted(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; using (Assert.Multiple()) { await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); @@ -446,7 +425,7 @@ public async Task SubscribeSynchronous_WithOnlyOnNext_ProcessesValues() subject.OnNext(1); subject.OnNext(SampleValue2); subject.OnNext(SampleValue3); - await allReceived.Task.WaitAsync(WaitTimeout); + await allReceived.Task; await Assert.That(results).IsCollectionEqualTo([1, SampleValue2, SampleValue3]); } @@ -468,7 +447,7 @@ public async Task SubscribeAsync_WithOnNextAndOnCompleted_CompletesCorrectly() using var subscription = source.SubscribeAsync( async v => { - await Task.Delay(1); + await Task.Yield(); results.Add(v); }, () => @@ -476,7 +455,7 @@ public async Task SubscribeAsync_WithOnNextAndOnCompleted_CompletesCorrectly() completed = true; _ = completionSource.TrySetResult(true); }); - await completionSource.Task.WaitAsync(WaitTimeout); + await completionSource.Task; using (Assert.Multiple()) { await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs index 50c50f5c..4d38931a 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs @@ -3,24 +3,21 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Subjects; -using ReactiveUI.Primitives.Async.Tests; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests; /// Tests for ReactiveExtensionsTests. public partial class ReactiveExtensionsTests { - /// Throttle/debounce window in milliseconds used by the real-time (non-virtual) throttle tests. - private const int ThrottleWindowMilliseconds = 100; + /// Throttle window in virtual ticks used by the real-time-free throttle tests. + private const int ThrottleWindowTicks = 100; - /// Longest a real-time test waits for a slow scheduled signal before failing. - private static readonly TimeSpan LongWaitTimeout = TimeSpan.FromSeconds(30); + /// Window used by the throttle tests that run on the default sequencer and await their emission. + private static readonly TimeSpan DefaultSequencerWindow = TimeSpan.FromMilliseconds(200); - /// Window used by the real-time throttle tests that poll for their result rather than advancing a clock. - private static readonly TimeSpan PolledThrottleWindow = TimeSpan.FromMilliseconds(200); - - /// Debounce window used by the real-time DebounceUntil test. + /// Debounce window used by the default-sequencer DebounceUntil test. private static readonly TimeSpan DebounceWindow = TimeSpan.FromMilliseconds(500); /// Tests DebounceImmediate emits first immediately. @@ -44,15 +41,14 @@ public async Task DebounceImmediate_EmitsFirstImmediately() [Test] public async Task ThrottleFirst_EmitsFirstImmediately_IgnoresSubsequentWithinWindow() { + VirtualClock scheduler = new(); Subject subject = new(); List results = []; - - // Throttle window of 100 ms - _ = subject.ThrottleFirst(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)).Subscribe(results.Add); + _ = subject.ThrottleFirst(TimeSpan.FromTicks(ThrottleWindowTicks), scheduler).Subscribe(results.Add); subject.OnNext(1); // Should be emitted immediately subject.OnNext(SampleValue2); // Should be ignored (within throttle window) subject.OnNext(SampleValue3); // Should be ignored (within throttle window) - await Task.Delay(ThrottleWaitMilliseconds); // Wait for throttle window to pass + scheduler.AdvanceBy(ThrottleWindowTicks + 1); // Move past the throttle window subject.OnNext(SampleValue4); // Should be emitted // Verify results @@ -66,11 +62,11 @@ public async Task DropIfBusy_DropsWhenBusy() { Subject subject = new(); List results = []; - TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(); + TaskCompletionSource processed = new(); _ = subject.DropIfBusy(async x => { - await release.Task; + await release.Task.ConfigureAwait(false); results.Add(x); processed.SetResult(); }).Subscribe(); @@ -78,7 +74,7 @@ public async Task DropIfBusy_DropsWhenBusy() subject.OnNext(SampleValue2); // Should drop subject.OnNext(SampleValue3); // Should drop release.SetResult(new()); // Complete the async action - await processed.Task.WaitAsync(WaitTimeout); + await processed.Task; await Assert.That(results).IsCollectionEqualTo([1]); } @@ -210,22 +206,22 @@ public async Task WhenDebounceUntilWithScheduler_ThenUsesSchedulerForDelay() [Test] public async Task WhenThrottleUntilTruePredicateFalse_ThenAppliesThrottle() { + VirtualClock scheduler = new(); Subject subject = new(); List results = []; - TaskCompletionSource throttledArrived = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var sub = subject.ThrottleUntilTrue(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), static x => x > PredicateThreshold) - .Subscribe(value => - { - results.Add(value); - _ = value == 1 && throttledArrived.TrySetResult(value); - }); + using var sub = new ThrottleUntilTrueObservable( + subject, + TimeSpan.FromTicks(ThrottleWindowTicks), + static x => x > PredicateThreshold, + scheduler).Subscribe(results.Add); // Predicate true: immediate. subject.OnNext(SampleValue10); - // Predicate false: throttled — wait on the event instead of racing a fixed delay. + // Predicate false: held until the clock passes the throttle window. subject.OnNext(1); - await throttledArrived.Task.WaitAsync(WaitTimeout); + await Assert.That(results).IsCollectionEqualTo([SampleValue10]); + scheduler.AdvanceBy(ThrottleWindowTicks + 1); await Assert.That(results).Contains(SampleValue10); await Assert.That(results).Contains(1); } @@ -237,11 +233,16 @@ public async Task WhenThrottleDistinctWithoutScheduler_ThenThrottlesAndDeduplica { Subject subject = new(); List results = []; - using var sub = subject.ThrottleDistinct(PolledThrottleWindow).Subscribe(results.Add); + TaskCompletionSource emitted = new(); + using var sub = subject.ThrottleDistinct(DefaultSequencerWindow).Subscribe(value => + { + results.Add(value); + _ = value == SampleValue2 && emitted.TrySetResult(); + }); subject.OnNext(1); subject.OnNext(1); subject.OnNext(SampleValue2); - await AsyncTestHelpers.WaitForConditionAsync(() => results.Contains(SampleValue2), LongWaitTimeout); + await emitted.Task; await Assert.That(results).Contains(SampleValue2); } diff --git a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs index b59351e2..6985f799 100644 --- a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs @@ -2,7 +2,6 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics; using System.Runtime.CompilerServices; using Microsoft.Maui.Dispatching; using ReactiveUI.Primitives.Concurrency; @@ -18,6 +17,9 @@ public sealed class MauiDispatcherSequencerTests /// Expected values produced by an immediate burst, used to verify FIFO order. private static readonly int[] ExpectedBurst = [1, 2, 3]; + /// Due time far enough out that the delay reaching the dispatcher is positive whatever the call costs. + private static readonly TimeSpan FutureDueTime = TimeSpan.FromHours(1); + /// Verifies the constructor rejects a null dispatcher. /// A representing the asynchronous operation. [Test] @@ -66,8 +68,8 @@ public async Task DelayedScheduleUsesDispatchDelayed() MauiDispatcherSequencer sequencer = new(dispatcher); var executed = false; - var due = sequencer.Timestamp + Stopwatch.Frequency; // ~1 second into the future. - sequencer.Schedule(new DelegateWorkItem(() => executed = true), due); + // The relative overload converts the due time against the sequencer's own clock, so the test needs none. + _ = sequencer.Schedule(FutureDueTime, () => executed = true); await Assert.That(executed).IsTrue(); await Assert.That(dispatcher.DispatchDelayedCount).IsEqualTo(1); @@ -83,7 +85,8 @@ public async Task PastDueTimestampUsesImmediatePath() MauiDispatcherSequencer sequencer = new(dispatcher); var executed = false; - var due = sequencer.Timestamp - Stopwatch.Frequency; // already elapsed. + // A timestamp read before the call cannot be later than the monotonic clock the sequencer reads inside it. + var due = sequencer.Timestamp; sequencer.Schedule(new DelegateWorkItem(() => executed = true), due); await Assert.That(executed).IsTrue(); diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs index 7eb3c4a4..763765f9 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs @@ -4,7 +4,6 @@ using System.Reactive.Disposables; using ReactiveUI.Primitives.Reactive.Concurrency; -using Timer = System.Threading.Timer; namespace ReactiveUI.Primitives.Reactive.Tests; @@ -14,39 +13,27 @@ public sealed class WasmSchedulerTests /// State payload used to verify state threading. private const int StatePayload = 42; - /// Minimum periodic ticks a test observes before disposing. - private const int MinimumTicks = 2; + /// Periodic ticks a test drives before disposing; more than one, so threaded state is observable. + private const int PeriodicTickCount = 2; - /// Threads that enqueue concurrently in the single-flight drain test. - private const int ProducerCount = 4; + /// Items queued ahead of a single drain pass in the exactly-once dispatch test. + private const int BatchItemCount = 2000; - /// Items each producer enqueues in the single-flight drain test. - private const int ItemsPerProducer = 500; + /// Sentinel returns when no element matches. + private const int NoMatch = -1; /// Expected values produced by an immediate burst, used to verify FIFO order. private static readonly int[] ExpectedBurst = [1, 2, 3]; - /// Longest a test waits for scheduled work before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Due time of a work item that is expected to run after its delay elapses. private static readonly TimeSpan DelayedDueTime = TimeSpan.FromMilliseconds(50); - /// Due time of a work item that is disposed before it becomes due, so it must never run. - private static readonly TimeSpan CancellationDueTime = TimeSpan.FromMilliseconds(100); - - /// How long a test waits past to prove a cancelled item did not run. - private static readonly TimeSpan CancellationObservationWindow = TimeSpan.FromMilliseconds(250); - - /// Period between ticks of a periodic work item. - private static readonly TimeSpan TickPeriod = TimeSpan.FromMilliseconds(10); - - /// How long a test waits after disposing a periodic item to prove no further ticks arrive. - private static readonly TimeSpan PostDisposeObservationWindow = TimeSpan.FromMilliseconds(100); - /// A positive due time or period, so a null action is the only invalid argument under test. private static readonly TimeSpan ValidInterval = TimeSpan.FromMilliseconds(100); + /// A due time no test waits out, so the only run a delayed item sees is the one the test drives. + private static readonly TimeSpan UnreachableDueTime = TimeSpan.FromHours(1); + /// A period no test waits out, so the only tick a periodic item sees is the one the test drives. private static readonly TimeSpan UnreachablePeriod = TimeSpan.FromHours(1); @@ -64,9 +51,9 @@ public async Task ScheduleRejectsNullAction() var scheduler = WasmScheduler.Default; await Assert.That(() => scheduler.Schedule(0, null!)).ThrowsExactly(); - await Assert.That(() => scheduler.Schedule(0, TimeSpan.FromMilliseconds(1), null!)) + await Assert.That(() => scheduler.Schedule(0, ValidInterval, null!)) .ThrowsExactly(); - await Assert.That(() => scheduler.SchedulePeriodic(0, TimeSpan.FromMilliseconds(1), null!)) + await Assert.That(() => scheduler.SchedulePeriodic(0, ValidInterval, null!)) .ThrowsExactly(); } @@ -93,7 +80,7 @@ public async Task ImmediateScheduleExecutes() return Disposable.Empty; }); - await Assert.That(await executed.Task.WaitAsync(WaitTimeout)).IsEqualTo(StatePayload); + await Assert.That(await executed.Task).IsEqualTo(StatePayload); } /// Verifies a burst of immediate work executes in FIFO order. @@ -119,49 +106,38 @@ public async Task ImmediateBurstExecutesInOrder() }); } - _ = await done.Task.WaitAsync(WaitTimeout); + _ = await done.Task; await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default); } - /// Verifies a disposed immediate work item never runs while later work still does. + /// Verifies an item disposed while it waits in the ready queue is skipped while later work still runs. /// A representing the asynchronous operation. [Test] public async Task DisposedImmediateItemIsSkipped() { - // Park the single event-loop drain on a gate item so the dispose below is guaranteed to happen before the - // cancelled item is ever run. Without this the immediate drain races the synchronous Dispose on a - // multi-threaded runtime (it never can on single-threaded WebAssembly, which the type targets). - TaskCompletionSource gateEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); - using ManualResetEventSlim release = new(false); - _ = WasmScheduler.Default.Schedule(0, (_, _) => - { - _ = gateEntered.TrySetResult(); - _ = release.Wait(WaitTimeout); - return Disposable.Empty; - }); - - await gateEntered.Task.WaitAsync(WaitTimeout); - - TaskCompletionSource markerRan = new(TaskCreationOptions.RunContinuationsAsynchronously); + using var scheduler = CreateIsolatedScheduler(); var cancelledRan = false; - - var cancelled = WasmScheduler.Default.Schedule(0, (_, _) => + var markerRan = false; + WasmScheduler.StatefulWorkItem cancelled = new(scheduler, 0, (_, _) => { cancelledRan = true; return Disposable.Empty; }); - cancelled.Dispose(); - _ = WasmScheduler.Default.Schedule(0, (_, _) => + WasmScheduler.StatefulWorkItem marker = new(scheduler, 0, (_, _) => { - _ = markerRan.TrySetResult(true); + markerRan = true; return Disposable.Empty; }); - // Let the drain proceed: it runs the gate, then the (now disposed) cancelled item, then the marker. - release.Set(); + // Queue both without arming a drain, so the dispose lands while a drain has reached neither item. + scheduler.QueueReady(cancelled); + scheduler.QueueReady(marker); + cancelled.Dispose(); + + scheduler.RunReadyBatch(); - _ = await markerRan.Task.WaitAsync(WaitTimeout); await Assert.That(cancelledRan).IsFalse(); + await Assert.That(markerRan).IsTrue(); } /// Verifies delayed work executes and zero due time uses the immediate path. @@ -183,25 +159,31 @@ public async Task DelayedScheduleExecutes() return Disposable.Empty; }); - await Assert.That(await delayed.Task.WaitAsync(WaitTimeout)).IsTrue(); - await Assert.That(await immediate.Task.WaitAsync(WaitTimeout)).IsTrue(); + await Assert.That(await delayed.Task).IsTrue(); + await Assert.That(await immediate.Task).IsTrue(); } - /// Verifies disposing a delayed work item before it is due cancels it. + /// Verifies disposing a delayed work item before it is due cancels it, so a late callback is dropped. /// A representing the asynchronous operation. [Test] public async Task DisposedDelayedItemDoesNotRun() { var ran = false; + var subscription = (WasmScheduler.StatefulWorkItem)WasmScheduler.Default.Schedule( + 0, + UnreachableDueTime, + (_, _) => + { + ran = true; + return Disposable.Empty; + }); - var subscription = WasmScheduler.Default.Schedule(0, CancellationDueTime, (_, _) => - { - ran = true; - return Disposable.Empty; - }); subscription.Dispose(); - await Task.Delay(CancellationObservationWindow); + // The due time never elapses on its own, so this is the run a timer callback already in flight would deliver. + subscription.Run(); + + await Assert.That(subscription.IsDisposed).IsTrue(); await Assert.That(ran).IsFalse(); } @@ -210,26 +192,28 @@ public async Task DisposedDelayedItemDoesNotRun() [Test] public async Task SchedulePeriodicTicksAndStopsOnDispose() { - TaskCompletionSource reachedTwo = new(TaskCreationOptions.RunContinuationsAsynchronously); var count = 0; + var subscription = (WasmScheduler.PeriodicWorkItem)WasmScheduler.Default.SchedulePeriodic( + 0, + UnreachablePeriod, + state => + { + count = state + 1; + return count; + }); - var subscription = WasmScheduler.Default.SchedulePeriodic(0, TickPeriod, state => + for (var tick = 0; tick < PeriodicTickCount; tick++) { - count = state + 1; - if (count >= MinimumTicks) - { - _ = reachedTwo.TrySetResult(true); - } + subscription.Tick(); + } - return count; - }); + // The count only reaches the tick total if each tick received the state the previous one returned. + await Assert.That(count).IsEqualTo(PeriodicTickCount); - _ = await reachedTwo.Task.WaitAsync(WaitTimeout); subscription.Dispose(); - var snapshot = Volatile.Read(ref count); + subscription.Tick(); - await Task.Delay(PostDisposeObservationWindow); - await Assert.That(Volatile.Read(ref count)).IsEqualTo(snapshot); + await Assert.That(count).IsEqualTo(PeriodicTickCount); } /// Verifies a zero period is clamped instead of rejected and still ticks. @@ -245,7 +229,7 @@ public async Task SchedulePeriodicClampsZeroPeriod() return state; }); - await Assert.That(await ticked.Task.WaitAsync(WaitTimeout)).IsTrue(); + await Assert.That(await ticked.Task).IsTrue(); subscription.Dispose(); } @@ -267,7 +251,7 @@ public async Task DisposeReleasesDrainTimerAndIsIdempotent() public async Task DisposedDelayedItemDisposeIsIdempotent() { var subscription = - WasmScheduler.Default.Schedule(0, TimeSpan.FromMinutes(1), static (_, _) => Disposable.Empty); + WasmScheduler.Default.Schedule(0, UnreachableDueTime, static (_, _) => Disposable.Empty); subscription.Dispose(); @@ -279,7 +263,7 @@ public async Task DisposedDelayedItemDisposeIsIdempotent() [Test] public async Task DisposedPeriodicItemDisposeIsIdempotent() { - var subscription = WasmScheduler.Default.SchedulePeriodic(0, TimeSpan.FromMinutes(1), static state => state); + var subscription = WasmScheduler.Default.SchedulePeriodic(0, UnreachablePeriod, static state => state); subscription.Dispose(); @@ -292,8 +276,7 @@ public async Task DisposedPeriodicItemDisposeIsIdempotent() public async Task SelfCancellingImmediateActionDisposesReturnedDisposable() { using var scheduler = CreateIsolatedScheduler(); - TaskCompletionSource returnedDisposed = new(TaskCreationOptions.RunContinuationsAsynchronously); - var returned = Disposable.Create(returnedDisposed, static source => source.TrySetResult()); + BooleanDisposable returned = new(); // Build the item and publish the handle it cancels through before anything can run it. Scheduling normally // arms the drain inside Schedule and only then returns the handle, so the action is free to run first and @@ -310,10 +293,10 @@ public async Task SelfCancellingImmediateActionDisposesReturnedDisposable() }); subscription = item; - scheduler.Enqueue(item); + scheduler.QueueReady(item); + scheduler.RunReadyBatch(); - await returnedDisposed.Task.WaitAsync(WaitTimeout); - await Assert.That(returnedDisposed.Task.IsCompletedSuccessfully).IsTrue(); + await Assert.That(returned.IsDisposed).IsTrue(); } /// Verifies that scheduling with a null action returns proper exception. @@ -358,24 +341,24 @@ public async Task ScheduleAfterDisposeThrowsObjectDisposedException() await Assert.That(() => scheduler.Schedule(0, (_, _) => { - _ = Interlocked.Increment(ref ran); + ran++; return Disposable.Empty; })).ThrowsExactly(); - await Assert.That(() => scheduler.Schedule(0, DelayedDueTime, (_, _) => + await Assert.That(() => scheduler.Schedule(0, UnreachableDueTime, (_, _) => { - _ = Interlocked.Increment(ref ran); + ran++; return Disposable.Empty; })).ThrowsExactly(); - await Assert.That(() => scheduler.SchedulePeriodic(0, TickPeriod, state => + await Assert.That(() => scheduler.SchedulePeriodic(0, UnreachablePeriod, state => { - _ = Interlocked.Increment(ref ran); + ran++; return state; })).ThrowsExactly(); - await Task.Delay(CancellationObservationWindow); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + // Each overload threw before it built an item, so there is nothing left that could run the actions. + await Assert.That(ran).IsEqualTo(0); } /// @@ -387,32 +370,23 @@ await Assert.That(() => scheduler.SchedulePeriodic(0, TickPeriod, state => public async Task DisposeCancelsWorkTheInFlightDrainHasNotReachedYet() { var scheduler = CreateIsolatedScheduler(); - TaskCompletionSource gateEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); - using ManualResetEventSlim release = new(false); - - // Park the single drain inside the first item, so the second item is provably still queued when Dispose runs. - _ = scheduler.Schedule(0, (_, _) => - { - _ = gateEntered.TrySetResult(); - _ = release.Wait(WaitTimeout); - return Disposable.Empty; - }); - await gateEntered.Task.WaitAsync(WaitTimeout); - var queuedRan = 0; - var queued = scheduler.Schedule(0, (_, _) => + WasmScheduler.StatefulWorkItem queued = new(scheduler, 0, (_, _) => { - _ = Interlocked.Increment(ref queuedRan); + queuedRan++; return Disposable.Empty; }); + // Queue without arming a drain, so the item is provably still waiting when the disposal runs. + scheduler.QueueReady(queued); + scheduler.Dispose(); - // Let the parked drain resume: the item it never reached must have been cancelled by the disposal. - release.Set(); - await Task.Delay(PostDisposeObservationWindow); + // The parked drain resuming: the item it never reached is gone from the queue the disposal released. + scheduler.RunReadyBatch(); - await Assert.That(Volatile.Read(ref queuedRan)).IsEqualTo(0); + await Assert.That(queuedRan).IsEqualTo(0); + await Assert.That(queued.IsDisposed).IsTrue(); await Assert.That(queued.Dispose).ThrowsNothing(); } @@ -432,27 +406,19 @@ public async Task AttachTimerReleasesATimerGivenToAnAlreadyCancelledItem() StatePayload, (_, _) => { - _ = Interlocked.Increment(ref ran); + ran++; return Disposable.Empty; }); // Cancel before the delayed schedule reaches its AttachTimer call. item.Dispose(); - var fired = 0; - await using Timer timer = new( - _ => Interlocked.Increment(ref fired), - null, - DelayedDueTime, - Timeout.InfiniteTimeSpan); - + // Stands in for the one-shot timer, recording the release the item owes it. + BooleanDisposable timer = new(); item.AttachTimer(timer); - // A timer still armed would have fired well inside this window; the released one never can. - await Task.Delay(CancellationObservationWindow); - - await Assert.That(Volatile.Read(ref fired)).IsEqualTo(0); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + await Assert.That(timer.IsDisposed).IsTrue(); + await Assert.That(ran).IsEqualTo(0); } /// @@ -470,7 +436,7 @@ public async Task PeriodicTickThatLosesTheRaceToDisposeDoesNotRunTheAction() UnreachablePeriod, state => { - _ = Interlocked.Increment(ref ticks); + ticks++; return state; }); @@ -479,7 +445,7 @@ public async Task PeriodicTickThatLosesTheRaceToDisposeDoesNotRunTheAction() // The period never elapses on its own, so this is the tick a callback already in flight would have delivered. item.Tick(); - await Assert.That(Volatile.Read(ref ticks)).IsEqualTo(0); + await Assert.That(ticks).IsEqualTo(0); } /// @@ -498,7 +464,7 @@ public async Task EnqueueThatLosesTheRaceToDisposeReleasesTheItemItQueued() StatePayload, (_, _) => { - _ = Interlocked.Increment(ref ran); + ran++; return Disposable.Empty; }); @@ -509,47 +475,36 @@ public async Task EnqueueThatLosesTheRaceToDisposeReleasesTheItemItQueued() await Assert.That(item.IsDisposed).IsTrue(); - await Task.Delay(PostDisposeObservationWindow); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + // A drain that arrives after the enqueue finds nothing to run, so the released item stays unrun. + scheduler.RunReadyBatch(); + + await Assert.That(ran).IsEqualTo(0); } /// - /// Verifies the single-flight drain latch dispatches every item exactly once when many threads enqueue at the same - /// time. Enqueues, drain posts and the running drain all interleave here, so a lost drain post would strand work - /// and a double-claimed latch would run an item twice. + /// Verifies a drain pass dispatches every queued item exactly once: an item the batch skips would be stranded, + /// and one it claims twice would run its action twice. /// /// A representing the asynchronous operation. [Test] - public async Task ConcurrentSchedulingDispatchesEveryItemExactlyOnce() + public async Task DrainDispatchesEveryQueuedItemExactlyOnce() { using var scheduler = CreateIsolatedScheduler(); - var ran = 0; - using CountdownEvent completed = new(ProducerCount * ItemsPerProducer); - var producers = new Task[ProducerCount]; + var dispatches = new int[BatchItemCount]; - for (var producer = 0; producer < ProducerCount; producer++) + for (var index = 0; index < BatchItemCount; index++) { - producers[producer] = Task.Run(() => + scheduler.QueueReady(new WasmScheduler.StatefulWorkItem(scheduler, index, (_, state) => { - for (var item = 0; item < ItemsPerProducer; item++) - { - _ = scheduler.Schedule(0, (_, _) => - { - _ = Interlocked.Increment(ref ran); - _ = completed.Signal(); - return Disposable.Empty; - }); - } - }); + dispatches[state]++; + return Disposable.Empty; + })); } - await Task.WhenAll(producers); - - await Assert.That(completed.Wait(WaitTimeout)).IsTrue(); + scheduler.RunReadyBatch(); - // Settle, then prove the latch never let a second drain re-run an item it had already dispatched. - await Task.Delay(PostDisposeObservationWindow); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(ProducerCount * ItemsPerProducer); + // Every slot holding exactly one dispatch rules out both a skipped and a doubled item. + await Assert.That(Array.FindIndex(dispatches, static count => count != 1)).IsEqualTo(NoMatch); } /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs new file mode 100644 index 00000000..e7e5b0db --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs @@ -0,0 +1,93 @@ +// 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.Concurrent; + +namespace ReactiveUI.Primitives.Tests; + +/// +/// An observer that records every callback and hands out tasks that complete when a callback arrives, so a test +/// awaits the notification it is asserting on instead of giving a clock a budget to produce it. +/// +/// The type of the observed values. +internal sealed class AwaitableWitness : IObserver +{ + /// The recorded values, in arrival order. + private readonly ConcurrentQueue _values = new(); + + /// The recorded errors, in arrival order. + private readonly ConcurrentQueue _errors = new(); + + /// The value-count thresholds a caller is waiting on, keyed by threshold. + private readonly ConcurrentDictionary _valueWaiters = new(); + + /// Produces the first observed error. + private readonly TaskCompletionSource _firstError = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes when the sequence completes. + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets a task that produces the first observed error. + internal Task FirstError => _firstError.Task; + + /// Gets a task that completes when the sequence completes. + internal Task Completion => _completion.Task; + + /// Gets a snapshot of the recorded values. + internal IReadOnlyList Values => [.. _values]; + + /// Gets a snapshot of the recorded errors. + internal IReadOnlyList Errors => [.. _errors]; + + /// Gets the number of completion callbacks observed. + internal int Completions { get; private set; } + + /// Gets a task that completes once the observed value count reaches a threshold. + /// The value count to wait for. + /// A task that completes when at least values have been observed. + internal Task ValueCountReaching(int count) + { + var waiter = _valueWaiters.GetOrAdd( + count, + static _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + + // Re-check after registering: a value that arrived in between would otherwise never signal this waiter. + if (_values.Count >= count) + { + _ = waiter.TrySetResult(); + } + + return waiter.Task; + } + + /// + public void OnCompleted() + { + Completions++; + _ = _completion.TrySetResult(); + } + + /// + public void OnError(Exception error) + { + _errors.Enqueue(error); + _ = _firstError.TrySetResult(error); + } + + /// + public void OnNext(T value) + { + _values.Enqueue(value); + var count = _values.Count; + foreach (var waiter in _valueWaiters) + { + if (waiter.Key <= count) + { + _ = waiter.Value.TrySetResult(); + } + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.cs new file mode 100644 index 00000000..e92862e3 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.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. + +namespace ReactiveUI.Primitives.Tests; + +/// Awaitable adapters over a . +internal static class CancellationTokenExtensions +{ + /// + /// Returns a task that stays pending until the token is cancelled and then throws + /// . It models work that only ever ends by cancellation, so a test can + /// hold a factory open for as long as it needs to without handing a deadline to a clock. + /// + /// The token whose cancellation ends the wait. + /// A task that transitions to cancelled when is cancelled. + internal static async Task WhenCanceled(this CancellationToken token) + { + // No RunContinuationsAsynchronously: the awaiting body resumes inside Cancel, so the cancellation path + // has run by the time the caller's Cancel call returns. + TaskCompletionSource completion = new(); + using var registration = token.Register( + static state => ((TaskCompletionSource)state!).TrySetCanceled(), + completion); + await completion.Task.ConfigureAwait(false); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs index ac35131a..6c479ba2 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs @@ -60,8 +60,8 @@ public partial class RxNamesTests /// The amount the virtual clock is advanced, comfortably past . private const long AdvanceTicks = 5; - /// The timeout in seconds used while waiting for ThreadPool-scheduled coverage branches. - private const int PollTimeoutSeconds = 2; + /// How far in the past the absolute due time of the default-scheduler operators is placed. + private const int ElapsedDueSeconds = 2; /// Source values 1..5. private static readonly int[] _oneToFive = [1, 2, 3, 4, 5]; @@ -439,62 +439,59 @@ await Assert [Test] public async Task AbsoluteTimeOperatorsUseDefaultScheduler() { - var dueTime = ThreadPoolSequencer.Instance.Now.AddSeconds(-PollTimeoutSeconds); - List delayedScalar = []; - List delayedRange = []; - List delayedSubscriptionScalar = []; - List delayedSubscriptionRange = []; - List delayedExplicitRange = []; - List delayedSubscriptionExplicitRange = []; - Exception? timeout = null; - Exception? explicitTimeout = null; + var dueTime = ThreadPoolSequencer.Instance.Now.AddSeconds(-ElapsedDueSeconds); + AwaitableWitness delayedScalar = new(); + AwaitableWitness delayedRange = new(); + AwaitableWitness delayedSubscriptionScalar = new(); + AwaitableWitness delayedSubscriptionRange = new(); + AwaitableWitness delayedExplicitRange = new(); + AwaitableWitness delayedSubscriptionExplicitRange = new(); + AwaitableWitness timeout = new(); + AwaitableWitness explicitTimeout = new(); const ISequencer? defaultScheduler = null; using var delayScalarSubscription = Signal.Emit(One) .Delay(dueTime) - .Subscribe(delayedScalar.Add); + .Subscribe(delayedScalar); using var delayRangeSubscription = Signal.Sequence(Two, Two) .Delay(dueTime) - .Subscribe(delayedRange.Add); + .Subscribe(delayedRange); using var delayExplicitRangeSubscription = Signal.Sequence(Two, Two) .Delay(dueTime, defaultScheduler) - .Subscribe(delayedExplicitRange.Add); + .Subscribe(delayedExplicitRange); using var subscriptionScalarSubscription = Signal.Emit(One) .DelaySubscription(dueTime) - .Subscribe(delayedSubscriptionScalar.Add); + .Subscribe(delayedSubscriptionScalar); using var subscriptionRangeSubscription = Signal.Sequence(Two, Two) .DelaySubscription(dueTime) - .Subscribe(delayedSubscriptionRange.Add); + .Subscribe(delayedSubscriptionRange); using var subscriptionExplicitRangeSubscription = Signal.Sequence(Two, Two) .DelaySubscription(dueTime, defaultScheduler) - .Subscribe(delayedSubscriptionExplicitRange.Add); + .Subscribe(delayedSubscriptionExplicitRange); using var timeoutSubscription = Signal.Silent() .Timeout(dueTime) - .Subscribe(static _ => { }, captured => timeout = captured); + .Subscribe(timeout); using var explicitTimeoutSubscription = Signal.Silent() .Timeout(dueTime, defaultScheduler) - .Subscribe(static _ => { }, captured => explicitTimeout = captured); - - await TestPolling.SpinUntil( - () => - delayedScalar.Count == One - && delayedRange.Count == Two - && delayedExplicitRange.Count == Two - && delayedSubscriptionScalar.Count == One - && delayedSubscriptionRange.Count == Two - && delayedSubscriptionExplicitRange.Count == Two - && timeout is not null - && explicitTimeout is not null, - TimeSpan.FromSeconds(PollTimeoutSeconds)); - - await Assert.That(delayedScalar.SequenceEqual([One])).IsTrue(); - await Assert.That(delayedRange.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedExplicitRange.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedSubscriptionScalar.SequenceEqual([One])).IsTrue(); - await Assert.That(delayedSubscriptionRange.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedSubscriptionExplicitRange.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(timeout).IsTypeOf(); - await Assert.That(explicitTimeout).IsTypeOf(); + .Subscribe(explicitTimeout); + + await delayedScalar.ValueCountReaching(One); + await delayedRange.ValueCountReaching(Two); + await delayedExplicitRange.ValueCountReaching(Two); + await delayedSubscriptionScalar.ValueCountReaching(One); + await delayedSubscriptionRange.ValueCountReaching(Two); + await delayedSubscriptionExplicitRange.ValueCountReaching(Two); + var timedOut = await timeout.FirstError; + var explicitlyTimedOut = await explicitTimeout.FirstError; + + await Assert.That(delayedScalar.Values.SequenceEqual([One])).IsTrue(); + await Assert.That(delayedRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(delayedExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(delayedSubscriptionScalar.Values.SequenceEqual([One])).IsTrue(); + await Assert.That(delayedSubscriptionRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(delayedSubscriptionExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(timedOut).IsTypeOf(); + await Assert.That(explicitlyTimedOut).IsTypeOf(); } /// Verifies the binary Concat/Chain overload concatenates two sequences identically. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs index 5ba040c8..a2322486 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs @@ -77,11 +77,11 @@ public async Task ParityAliasesCoverTaskObservableBranches() await Assert.That(observed).IsSameReferenceAs(expected); TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); - List pendingValues = []; - _ = pending.Task.ToObservable().Subscribe(pendingValues.Add); + AwaitableWitness pendingWitness = new(); + _ = pending.Task.ToObservable().Subscribe(pendingWitness); pending.SetResult(Two); - await TestPolling.SpinUntil(() => pendingValues.Count == One, TimeSpan.FromSeconds(One)); - await Assert.That(pendingValues.SequenceEqual([Two])).IsTrue(); + await pendingWitness.ValueCountReaching(One); + await Assert.That(pendingWitness.Values.SequenceEqual([Two])).IsTrue(); } /// Verifies parity operators cover remaining public range and alias branches. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs index 5a3235a6..7190e35c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs @@ -46,9 +46,16 @@ static async IAsyncEnumerable ThrowingAsyncEnumerable() throw new InvalidOperationException("async"); } + TaskCompletionSource asyncFailed = new(TaskCreationOptions.RunContinuationsAsynchronously); _ = Signal.FromAsyncEnumerable(ThrowingAsyncEnumerable()) - .Subscribe(asyncValues.Add, ex => asyncErrors.Add(ex.Message)); - await TestPolling.SpinUntil(() => asyncErrors.Count == 1, TimeSpan.FromSeconds(TimeoutSeconds)); + .Subscribe( + asyncValues.Add, + ex => + { + asyncErrors.Add(ex.Message); + _ = asyncFailed.TrySetResult(); + }); + await asyncFailed.Task; var firstFailure = await AssertTaskFault( static () => Signal.None().FirstAsync(), typeof(InvalidOperationException)); @@ -73,8 +80,15 @@ static async IAsyncEnumerable ThrowingAsyncEnumerable() /// A task that completes when the error has been observed. private static async Task ObserveTaskError(Task task, List errors) { - _ = Signal.FromTask(task).Subscribe(static _ => { }, ex => errors.Add(ex.GetType().Name)); - await TestPolling.SpinUntil(() => errors.Count > 0, TimeSpan.FromSeconds(TimeoutSeconds)); + TaskCompletionSource failed = new(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Signal.FromTask(task).Subscribe( + static _ => { }, + ex => + { + errors.Add(ex.GetType().Name); + _ = failed.TrySetResult(); + }); + await failed.Task; } /// Asserts that a task factory faults with the expected exception type. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs index 6326329f..3a596db4 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs @@ -301,7 +301,7 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to try { yield return FirstValue; - await Task.Delay(Timeout.InfiniteTimeSpan, token); + await token.WhenCanceled(); yield return SecondValue; } finally @@ -319,9 +319,9 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to }, static _ => { }, static () => { }); - await firstValueObserved.Task.WaitAsync(testToken).ConfigureAwait(false); + await firstValueObserved.Task.ConfigureAwait(false); subscription.Dispose(); - await disposedSignal.Task.WaitAsync(testToken).ConfigureAwait(false); + await disposedSignal.Task.ConfigureAwait(false); await Assert.That(values.SequenceEqual(AsyncEnumerableBeforeDisposeExpected)).IsTrue(); await Assert.That(disposed).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs index 1cd44430..2c768ce3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs @@ -12,9 +12,6 @@ namespace ReactiveUI.Primitives.Tests; /// public partial class SignalFactoriesTests { - /// The time allowed for a default-sequencer factory to produce its notification. - private static readonly TimeSpan DefaultSequencerTimeout = TimeSpan.FromSeconds(5); - /// The timeout used by the expiry factory test. private static readonly TimeSpan ShortExpiry = TimeSpan.FromMilliseconds(20); @@ -57,11 +54,11 @@ public async Task FromEnumerableHonorsACancellableTokenAndIgnoresAnUncancellable [Test] public async Task ExpireWithoutASequencerFailsASilentSequence() { - List errors = []; - using var subscription = Signal.Expire(Signal.Silent(), ShortExpiry) - .Subscribe(static _ => { }, errors.Add); - await TestPolling.SpinUntil(() => errors.Count == 1, DefaultSequencerTimeout); - await Assert.That(errors[0]).IsTypeOf(); + AwaitableWitness witness = new(); + using var subscription = Signal.Expire(Signal.Silent(), ShortExpiry).Subscribe(witness); + var error = await witness.FirstError; + await Assert.That(error).IsTypeOf(); + await Assert.That(witness.Errors.Count).IsEqualTo(1); } /// Verifies the sequencer-free Start factories run their work and emit its outcome. @@ -69,21 +66,21 @@ public async Task ExpireWithoutASequencerFailsASilentSequence() [Test] public async Task StartWithoutASequencerRunsTheWorkOnTheDefaultSequencer() { - List functionValues = []; - using var functionSubscription = Signal.Start(static () => Two).Subscribe(functionValues.Add); - await TestPolling.SpinUntil(() => functionValues.Count == 1, DefaultSequencerTimeout); - await Assert.That(functionValues.SequenceEqual([Two])).IsTrue(); + AwaitableWitness functionWitness = new(); + using var functionSubscription = Signal.Start(static () => Two).Subscribe(functionWitness); + await functionWitness.ValueCountReaching(1); + await Assert.That(functionWitness.Values.SequenceEqual([Two])).IsTrue(); var actionRuns = 0; - List actionValues = []; + AwaitableWitness actionWitness = new(); // A void method group is what selects Start(Action); a lambda over 'actionRuns++' is a // Func and would bind to the generic Start overload instead. void RunAction() => actionRuns++; - using var actionSubscription = Signal.Start(RunAction).Subscribe(actionValues.Add); - await TestPolling.SpinUntil(() => actionValues.Count == 1, DefaultSequencerTimeout); + using var actionSubscription = Signal.Start(RunAction).Subscribe(actionWitness); + await actionWitness.ValueCountReaching(1); await Assert.That(actionRuns).IsEqualTo(1); - await Assert.That(actionValues[0]).IsEqualTo(RxVoid.Default); + await Assert.That(actionWitness.Values[0]).IsEqualTo(RxVoid.Default); } /// Verifies the sequencer-free Every factory ticks on the default sequencer. @@ -91,12 +88,13 @@ public async Task StartWithoutASequencerRunsTheWorkOnTheDefaultSequencer() [Test] public async Task EveryWithoutASequencerTicksOnTheDefaultSequencer() { - List ticks = []; - using (Signal.Every(ShortExpiry).Subscribe(ticks.Add)) + AwaitableWitness witness = new(); + using (Signal.Every(ShortExpiry).Subscribe(witness)) { - await TestPolling.SpinUntil(() => ticks.Count >= Two, DefaultSequencerTimeout); + await witness.ValueCountReaching(Two); } + var ticks = witness.Values; await Assert.That(ticks[1]).IsGreaterThan(ticks[0]); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs index 9decea41..7380390c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs @@ -251,16 +251,16 @@ public async Task ScheduledSignalSendsToTheDefaultObserverWhileNoSubscriberIsPre [Test] public async Task FromAsyncWithAnUncancellableTokenStillCompletesWithItsResult() { - RecordingWitness witness = new(); + AwaitableWitness witness = new(); using var subscription = Signal .FromAsync(static _ => Task.FromResult(Seven), CancellationToken.None) .Subscribe(witness); - await TestPolling.SpinUntil(() => witness.Completed == 1, TimeSpan.FromSeconds(TimeoutSeconds)); + await witness.Completion; await Assert.That(witness.Values.SequenceEqual(ExpectedSingleSeven)).IsTrue(); - await Assert.That(witness.Completed).IsEqualTo(1); + await Assert.That(witness.Completions).IsEqualTo(1); await Assert.That(witness.Errors.Count).IsEqualTo(0); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs index 9ab1e57d..86d01d7e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs @@ -140,9 +140,6 @@ public async Task FactoryAliasesScheduledRangesTasksAndTimersCoverRemainderBranc .Subscribe(taskValues.Add, ex => taskErrors.Add(ex.GetType().Name)); _ = Signal.FromTask(Task.FromCanceled(new(true))) .Subscribe(taskValues.Add, ex => taskErrors.Add(ex.GetType().Name)); - await TestPolling.SpinUntil( - () => taskValues.Count == One && taskErrors.Count == Two, - TimeSpan.FromSeconds(TimeoutSeconds)); var disposedTaskSubscription = Signal.FromTask(Task.FromResult(NinetyNine)) .Subscribe(_ => taskValues.Add(NinetyNine)); disposedTaskSubscription.Dispose(); @@ -245,13 +242,13 @@ public async Task FromAsyncCancellableFactoryDisposalCancelsSubscriptionToken() { TaskCompletionSource observedToken = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource canceled = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness observer = new(); + AwaitableWitness observer = new(); var subscription = Signal.FromAsync(async token => { observedToken.SetResult(token); try { - await Task.Delay(TimeSpan.FromSeconds(TimeoutSeconds), token).ConfigureAwait(false); + await token.WhenCanceled().ConfigureAwait(false); } catch (OperationCanceledException) { @@ -261,13 +258,13 @@ public async Task FromAsyncCancellableFactoryDisposalCancelsSubscriptionToken() return One; }).Subscribe(observer); - var token = await observedToken.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + var token = await observedToken.Task; subscription.Dispose(); - await canceled.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + await canceled.Task; await Assert.That(token.IsCancellationRequested).IsTrue(); await Assert.That(observer.Values.Count).IsEqualTo(0); await Assert.That(observer.Errors.Count).IsEqualTo(0); - await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Completions).IsEqualTo(0); } /// Verifies that disposal suppresses a task result when the factory ignores cancellation. @@ -277,19 +274,19 @@ public async Task FromAsyncCancellableFactoryDisposalSuppressesIgnoredCancellati { TaskCompletionSource subscribed = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource complete = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness observer = new(); + AwaitableWitness observer = new(); var subscription = Signal.FromAsync(token => { subscribed.SetResult(); return complete.Task; }).Subscribe(observer); - await subscribed.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + await subscribed.Task; subscription.Dispose(); complete.SetResult(NinetyNine); await Task.Yield(); await Assert.That(observer.Values.Count).IsEqualTo(0); await Assert.That(observer.Errors.Count).IsEqualTo(0); - await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Completions).IsEqualTo(0); } /// Verifies that external token cancellation remains a source error while subscribed. @@ -299,21 +296,22 @@ public async Task FromAsyncCancellableFactoryExternalCancellationForwardsObserve { using CancellationTokenSource external = new(); TaskCompletionSource observedToken = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness observer = new(); + AwaitableWitness observer = new(); using var subscription = Signal.FromAsync( async token => { observedToken.SetResult(token); - await Task.Delay(TimeSpan.FromSeconds(TimeoutSeconds), token).ConfigureAwait(false); + await token.WhenCanceled().ConfigureAwait(false); return One; }, external.Token).Subscribe(observer); - await observedToken.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + await observedToken.Task; await external.CancelAsync(); - await TestPolling.SpinUntil(() => observer.Errors.Count == One, TimeSpan.FromSeconds(TimeoutSeconds)); - await Assert.That(observer.Errors[0]).IsTypeOf(); + var error = await observer.FirstError; + await Assert.That(error).IsTypeOf(); + await Assert.That(observer.Errors.Count).IsEqualTo(One); await Assert.That(observer.Values.Count).IsEqualTo(0); - await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Completions).IsEqualTo(0); } /// Verifies that external token cancellation forwards an error even when the task ignores the linked token. @@ -324,7 +322,7 @@ public async Task FromAsyncCancellableFactoryExternalCancellationForwardsObserve using CancellationTokenSource external = new(); TaskCompletionSource subscribed = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource complete = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness observer = new(); + AwaitableWitness observer = new(); using var subscription = Signal.FromAsync( token => { @@ -332,15 +330,15 @@ public async Task FromAsyncCancellableFactoryExternalCancellationForwardsObserve return complete.Task; }, external.Token).Subscribe(observer); - await subscribed.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + await subscribed.Task; await external.CancelAsync(); - await TestPolling.SpinUntil(() => observer.Errors.Count == One, TimeSpan.FromSeconds(TimeoutSeconds)); + var error = await observer.FirstError; complete.SetResult(NinetyNine); await Task.Yield(); - await Assert.That(observer.Errors[0]).IsTypeOf(); + await Assert.That(error).IsTypeOf(); await Assert.That(observer.Errors.Count).IsEqualTo(One); await Assert.That(observer.Values.Count).IsEqualTo(0); - await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Completions).IsEqualTo(0); } /// Verifies that cancellable async factories emit the successful task result and complete. @@ -430,13 +428,11 @@ public async Task FromAsyncCancellableFactoryCanceledTasksForwardObserverErrors( public async Task FromAsyncCancellableFactoryPendingTasksForwardTerminalContinuations() { TaskCompletionSource successfulTask = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness successful = new(); + AwaitableWitness successful = new(); using (Signal.FromAsync(_ => successfulTask.Task).Subscribe(successful)) { successfulTask.SetResult(Seven); - await TestPolling.SpinUntil( - () => successful.Completed == One, - TimeSpan.FromSeconds(TimeoutSeconds)); + await successful.Completion; } await Assert.That(successful.Values.SequenceEqual(ExpectedSingleSeven)).IsTrue(); @@ -444,34 +440,32 @@ await TestPolling.SpinUntil( InvalidOperationException expected = new("from-async-pending-fault"); TaskCompletionSource faultedTask = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness faulted = new(); + AwaitableWitness faulted = new(); using (Signal.FromAsync(_ => faultedTask.Task).Subscribe(faulted)) { faultedTask.SetException(expected); - await TestPolling.SpinUntil( - () => faulted.Errors.Count == One, - TimeSpan.FromSeconds(TimeoutSeconds)); + await faulted.FirstError; } + await Assert.That(faulted.Errors.Count).IsEqualTo(One); await Assert.That(faulted.Errors[0]).IsSameReferenceAs(expected); await Assert.That(faulted.Values.Count).IsEqualTo(0); - await Assert.That(faulted.Completed).IsEqualTo(0); + await Assert.That(faulted.Completions).IsEqualTo(0); using CancellationTokenSource cancellation = new(); await cancellation.CancelAsync(); TaskCompletionSource canceledTask = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness canceled = new(); + AwaitableWitness canceled = new(); using (Signal.FromAsync(_ => canceledTask.Task).Subscribe(canceled)) { canceledTask.SetCanceled(cancellation.Token); - await TestPolling.SpinUntil( - () => canceled.Errors.Count == One, - TimeSpan.FromSeconds(TimeoutSeconds)); + await canceled.FirstError; } + await Assert.That(canceled.Errors.Count).IsEqualTo(One); await Assert.That(canceled.Errors[0]).IsTypeOf(); await Assert.That(canceled.Values.Count).IsEqualTo(0); - await Assert.That(canceled.Completed).IsEqualTo(0); + await Assert.That(canceled.Completions).IsEqualTo(0); } /// Verifies direct timeout and runner factory APIs without extension method syntax. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs index 2caec3ad..a7ac4e1e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs @@ -75,9 +75,6 @@ public partial class SignalOperatorMixinsTests /// The expected error type name from the expire-timeout branch. private static readonly string[] ExpectedTimeoutErrors = [nameof(TimeoutException)]; - /// How long a disposed subscription is given to prove it forwards nothing after disposal. - private static readonly TimeSpan PostDisposalSettleDelay = TimeSpan.FromMilliseconds(50); - /// The expected single true value emitted by the true signal. private static readonly bool[] ExpectedTrueValues = [true]; @@ -225,10 +222,10 @@ public async Task PrimitiveShortcutBranchesCoverCancelableFallbackAndPendingTask _ = Assert.Throws(static () => ((IObservable)null!).ToSignal()); TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingWitness pendingSignal = new(); + AwaitableWitness pendingSignal = new(); using var pendingSubscription = pending.Task.ToSignal().Subscribe(pendingSignal); pending.SetResult(Three); - await TestPolling.SpinUntil(() => pendingSignal.Values.Count == 1, TimeSpan.FromSeconds(One)); + await pendingSignal.ValueCountReaching(1); await Assert.That(pendingSignal.Values.SequenceEqual([Three])).IsTrue(); RecordingWitness emptySwitch = new(); @@ -519,25 +516,21 @@ public async Task TaskChainDirectSignalKeepsPendingTasksInSourceOrder() TaskCompletionSource first = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource second = new(TaskCreationOptions.RunContinuationsAsynchronously); Signal> source = new(); - RecordingWitness chained = new(); + AwaitableWitness chained = new(); using var subscription = source.Chain().Subscribe(chained); source.OnNext(first.Task); source.OnNext(second.Task); source.OnCompleted(); + // The later task finishes first; forwarding it out of turn would land Two ahead of One. second.SetResult(Two); - await Task.Yield(); - await Assert.That(chained.Values.Count).IsEqualTo(0); - first.SetResult(One); - await TestPolling.SpinUntil( - () => chained.Values.Count == Two && chained.Completed == One, - TimeSpan.FromSeconds(One)); + await chained.Completion; await Assert.That(chained.Values.SequenceEqual(ExpectedOneTwo)).IsTrue(); await Assert.That(chained.Errors.Count).IsEqualTo(0); - await Assert.That(chained.Completed).IsEqualTo(One); + await Assert.That(chained.Completions).IsEqualTo(One); } /// Verifies direct task-chain terminal and disposal paths. @@ -590,7 +583,7 @@ public async Task TaskChainDirectSignalHandlesErrorsAndDisposal() pending.SetResult(Five); } - await Task.Delay(PostDisposalSettleDelay); + await Task.Yield(); await Assert.That(disposed.Values.Count).IsEqualTo(0); await Assert.That(disposed.Errors.Count).IsEqualTo(0); await Assert.That(disposed.Completed).IsEqualTo(0); From 86dc5bb4f8f204ca7659f1caece98422dedf9ba6 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:13:34 +1000 Subject: [PATCH 02/11] refactor(tests): drive scheduling tests from state, not the clock - Replace delays, polling and thread rendezvous with awaited completion sources, virtual clocks and ordered calls into internal seams. - Add seams for stepwise dispatch: WasmScheduler.QueueReady and RunReadyBatch, so enqueue, dispose and drain are explicit steps. - Assert per-item dispatch counts where a total was checked before. - Trim documentation to the contract: concise summaries, inline notes at the confusing line, and remarks only for non-obvious behaviour. - Correct exception docs to the type thrown rather than the guard helper. --- src/Primitives.Async.Shared/AsyncContext.cs | 68 +- .../Mixins/AsyncContextExtensions.cs | 4 +- .../Observables/FromAsync.Reactive.cs | 7 +- .../Observables/ToAsyncSignal.Reactive.cs | 6 +- .../Operators/ContextSwitchSignalAsync.cs | 11 +- .../Operators/WitnessOn.cs | 6 - .../Operators/Yield.cs | 10 +- .../Components/ReactiveComponentBase.cs | 6 +- .../InvalidOperationExceptionHelper.cs | 15 +- .../Internal/ScheduledDrainState.cs | 19 +- .../ObservableSubscriptionExtensions.cs | 10 +- .../Operators/AsSignalObservable.cs | 5 +- .../Operators/BufferUntilIdleObservable.cs | 5 +- .../Operators/ConflateObservable.cs | 106 +- .../Operators/ForEachObservable.cs | 5 +- .../Operators/FromArrayObservable.cs | 5 +- .../Operators/ObserveOnObservable.cs | 12 +- .../Operators/RetryBackoffPolicy.cs | 5 +- .../Operators/RunAllObservable.cs | 28 +- .../Operators/ScheduledSourceObservable.cs | 15 +- .../Operators/ScheduledValueObservable.cs | 10 +- .../Operators/StartFuncObservable.cs | 5 +- .../Operators/SyncTimerObservable.cs | 10 +- .../Operators/ThrottleDistinctObservable.cs | 6 +- .../Operators/ThrottleObservable.cs | 19 +- .../Operators/UsingFuncObservable.cs | 5 +- .../Operators/WhileObservable.cs | 5 +- .../ReactiveExtensions.Retry.cs | 5 +- .../ReactiveExtensions.cs | 11 +- .../CoalescingDispatchScheduler.cs | 24 +- .../DispatchWorkItemBase.cs | 12 +- .../Advanced/AsyncDeferSignal{T}.cs | 2 +- .../Advanced/AutoShareSignal{T}.cs | 7 +- .../Advanced/CreateSafeSignal{T}.cs | 3 +- .../Advanced/EventHandlerScope.cs | 6 +- .../Advanced/ExpireCoordinator{T}.cs | 23 +- .../ForkJoinWitness{TLeft,TRight,TResult}.cs | 3 +- .../Advanced/GuardedWitness{T}.cs | 5 +- .../Advanced/IsEmptySignal{T}.cs | 5 +- .../Advanced/MergeCoordinator{T}.cs | 4 +- .../Advanced/ResumeSignal{T}.cs | 5 +- .../Advanced/SubscriptionScheduling.cs | 9 +- .../SwitchMapSignal{TSource,TResult}.cs | 8 +- .../Advanced/SwitchWitness{T}.cs | 5 +- .../Advanced/TaskInstanceSignal{T}.cs | 2 +- src/Primitives.Shared/Advanced/TimerSlot.cs | 6 +- .../Concurrency/SequencerWorkItemDisposal.cs | 6 +- .../Core/SparkCompositeFormats.cs | 6 +- src/Primitives.Shared/Core/Spark{T}.cs | 10 +- ...SignalOperatorMixins.ChooseSwitchSelect.cs | 5 +- .../SignalOperatorMixins.CombineLatest.cs | 5 +- .../SignalOperatorMixins.Coordinators.cs | 9 +- .../SignalOperatorMixins.SchedulerSignals.cs | 215 +-- .../SignalOperatorMixins.StatefulSignals.cs | 12 +- .../SignalOperatorMixins.Switch.cs | 45 +- ...alOperatorMixins.SyncLatest.MultiSource.cs | 35 +- ...gnalOperatorMixins.SyncLatest.WideArity.cs | 35 +- src/Primitives.Shared/SignalOperatorMixins.cs | 5 +- ...alOperatorParityMixins.AggregateHelpers.cs | 7 +- ...atorParityMixins.BooleanTerminalHelpers.cs | 7 +- .../SignalOperatorParityMixins.FlatMap.cs | 4 +- .../SignalOperatorParityMixins.Helpers.cs | 4 +- .../SignalOperatorParityMixins.Probe.cs | 4 +- ...rityMixins.RxNames.CombineLatest.Tuples.cs | 1 - ...yMixins.RxNames.CombineLatest.WideArity.cs | 35 +- ...ratorParityMixins.RxNames.CombineLatest.cs | 35 +- .../SignalOperatorParityMixins.cs | 6 +- .../Signals/PrioritySemaphoreSignal{T}.cs | 9 +- .../Signals/ReplaySignal{T}.cs | 8 +- .../Signals/SignalExtensions{Recover}.cs | 4 +- .../Signals/Signal{Create}.cs | 20 +- .../Signals/Signal{FromTask}.cs | 145 +- .../Signals/TaskSignal{T}.cs | 6 +- .../Signals/TaskTerminalCompletion{T}.cs | 6 +- .../Disposables/DisposableSlotHelper.cs | 46 +- .../Disposables/Scope.cs | 2 +- .../Advanced/AsyncSerialGate.cs | 73 +- .../Advanced/FlatMapCoordinator{TResult}.cs | 18 +- .../Advanced/IReentrantAsyncDisposable.cs | 4 +- .../Advanced/PooledDelaySource.cs | 14 +- .../Advanced/SyncLatestCoordinatorBase.cs | 6 +- .../SyncLatestWitness{TSource,TResult}.cs | 5 +- .../Advanced/TakeUntilSourceWitness.cs | 5 +- .../Advanced/TaskSignalSubscription{T}.cs | 5 +- .../Advanced/TimerSignal.cs | 4 +- .../ConcurrencyRaceHelpers.cs | 14 +- .../ConcurrentWitnessCallsException.cs | 5 +- .../ConnectableSignalAsync.cs | 9 +- .../ConnectableSignalAsyncHelper.cs | 3 +- .../Disposables/DisposableAsync.cs | 5 +- .../Disposables/DisposableAsyncSlot.cs | 48 +- .../Disposables/MultipleDisposableAsync.cs | 25 +- .../SingleAssignmentDisposableAsync.cs | 3 +- .../SingleReplaceableDisposableAsync.cs | 11 +- .../Helpers/DisposalHelper.cs | 2 - .../Helpers/FireAndForgetHelper.cs | 10 +- .../Helpers/SubscriptionHelper.cs | 8 +- .../IObserverAsync.cs | 5 +- .../Mixins/DisposableAsyncExtensions.cs | 5 +- .../Mixins/SignalExtensions.cs | 15 +- .../Observables/Create.cs | 10 +- .../Observables/Defer.cs | 17 +- .../Observables/Empty.cs | 5 +- .../Observables/Return.cs | 5 +- .../Observables/Timer.cs | 19 +- .../Observables/ToAsyncSignal.cs | 18 +- .../Operators/Blend.cs | 54 +- .../Operators/Chain.cs | 10 +- .../Operators/ChainEnumerableSignal{T}.cs | 19 +- .../Operators/ChainSignalSourcesSignal{T}.cs | 34 +- .../Operators/CompletionSignalDelegate.cs | 5 +- .../Operators/Distinct.cs | 20 +- .../Operators/Expire.cs | 43 +- .../Operators/FirstOrDefaultAsync.cs | 20 +- .../Operators/FlatMap.cs | 5 +- .../Operators/Fold.cs | 10 +- .../Operators/GroupBy.cs | 5 +- .../Operators/Keep.cs | 20 +- .../Operators/KeepType.cs | 5 +- .../Operators/LastOrDefaultAsync.cs | 20 +- .../Operators/Lead.cs | 10 +- .../Operators/Multicast.cs | 35 +- .../ParityHelpers.OperatorFusions.cs | 47 +- .../Operators/ParityHelpers.Partition.cs | 10 +- .../Operators/ParityHelpers.cs | 5 +- .../Operators/Recover.cs | 10 +- .../Operators/ReduceAsync.cs | 60 +- .../Operators/RefCount.cs | 15 +- .../Operators/Shift.cs | 5 +- .../SignalAsyncExtensions.Catch.Rx.cs | 10 +- .../SignalAsyncExtensions.CombineLatest.Rx.cs | 5 +- .../SignalAsyncExtensions.Delay.Rx.cs | 10 +- ...AsyncExtensions.DistinctUntilChanged.Rx.cs | 15 +- .../SignalAsyncExtensions.Scan.Rx.cs | 5 +- .../SignalAsyncExtensions.SelectMany.Rx.cs | 5 +- .../Operators/SingleAsync.cs | 25 +- .../Operators/SingleOrDefaultAsync.cs | 10 +- .../Operators/SkipWhile.cs | 10 +- .../Operators/SubscribeAsync.cs | 20 +- .../Operators/SwitchTo.cs | 10 +- .../Operators/SwitchToSignal.cs | 30 +- .../Operators/SyncLatest10.cs | 10 +- .../Operators/SyncLatest11.cs | 10 +- .../Operators/SyncLatest12.cs | 10 +- .../Operators/SyncLatest13.cs | 10 +- .../Operators/SyncLatest14.cs | 10 +- .../Operators/SyncLatest15.cs | 10 +- .../Operators/SyncLatest16.cs | 10 +- .../Operators/SyncLatest3.cs | 10 +- .../Operators/SyncLatest4.cs | 10 +- .../Operators/SyncLatest5.cs | 10 +- .../Operators/SyncLatest6.cs | 10 +- .../Operators/SyncLatest7.cs | 10 +- .../Operators/SyncLatest8.cs | 10 +- .../Operators/SyncLatest9.cs | 10 +- .../Operators/SyncLatestEnumerable.cs | 11 +- .../Operators/TakeUntil.cs | 27 +- .../Operators/Tap.cs | 10 +- .../Operators/Throttle.cs | 71 +- .../Operators/ToAsyncEnumerable.cs | 15 +- .../Operators/ToDictionaryAsync.cs | 14 +- .../Operators/ToListAsync.cs | 3 - .../Operators/Unique.cs | 33 +- .../Operators/Using.cs | 19 +- .../PublicAPI/net10.0/PublicAPI.txt | 6 +- .../PublicAPI/net11.0/PublicAPI.txt | 6 +- .../PublicAPI/net462/PublicAPI.txt | 6 +- .../PublicAPI/net472/PublicAPI.txt | 6 +- .../PublicAPI/net48/PublicAPI.txt | 6 +- .../PublicAPI/net481/PublicAPI.txt | 6 +- .../PublicAPI/net8.0/PublicAPI.txt | 6 +- .../PublicAPI/net9.0/PublicAPI.txt | 6 +- .../Signals/Base/Concurrent.cs | 27 +- ...yLatestSignalAsyncStateObserverLease{T}.cs | 3 - .../Base/ReplayLatestSignalAsyncState{T}.cs | 6 +- .../Signals/Base/SerialBroadcastHelpers.cs | 15 +- ...yLatestSignalAsyncStateObserverLease{T}.cs | 3 - ...tatelessReplayLatestSignalAsyncState{T}.cs | 6 +- .../ConcurrentReplayLatestSignalAsync.cs | 9 +- .../Signals/ConcurrentSignalAsync.cs | 4 +- ...currentStatelessReplayLatestSignalAsync.cs | 10 +- .../Signals/ConcurrentStatelessSignalAsync.cs | 5 +- .../Signals/ISignalAsync.cs | 11 +- .../Options/BehaviorSignalCreationOptions.cs | 2 - .../Signals/Options/PublishingOption.cs | 19 +- .../ReplayLatestSignalCreationOptions.cs | 12 +- .../Signals/Options/SignalCreationOptions.cs | 11 +- .../Signals/SerialReplayLatestSignalAsync.cs | 6 +- .../Signals/SerialSignalAsync.cs | 9 +- .../SerialStatelessReplayLatestSignalAsync.cs | 10 +- .../Signals/SerialStatelessSignalAsync.cs | 4 +- .../Signals/Signal.cs | 28 +- .../UnhandledExceptionHandler.cs | 5 +- .../WitnessAsync.cs | 202 ++- .../SequencerSchedulingExtensions.cs | 6 +- .../PublicAPI/net10.0/PublicAPI.txt | 1 - .../PublicAPI/net11.0/PublicAPI.txt | 1 - .../PublicAPI/net462/PublicAPI.txt | 1 - .../PublicAPI/net472/PublicAPI.txt | 1 - .../PublicAPI/net48/PublicAPI.txt | 1 - .../PublicAPI/net481/PublicAPI.txt | 1 - .../PublicAPI/net8.0/PublicAPI.txt | 1 - .../PublicAPI/net9.0/PublicAPI.txt | 1 - .../PublicAPI/net10.0/PublicAPI.txt | 1 - .../PublicAPI/net11.0/PublicAPI.txt | 1 - .../PublicAPI/net462/PublicAPI.txt | 1 - .../PublicAPI/net472/PublicAPI.txt | 1 - .../PublicAPI/net48/PublicAPI.txt | 1 - .../PublicAPI/net481/PublicAPI.txt | 1 - .../PublicAPI/net8.0/PublicAPI.txt | 1 - .../PublicAPI/net9.0/PublicAPI.txt | 1 - .../Concurrency/AvaloniaScheduler.cs | 1 + .../PublicAPI/net10.0/PublicAPI.txt | 1 + .../PublicAPI/net11.0/PublicAPI.txt | 1 + .../PublicAPI/net8.0/PublicAPI.txt | 1 + .../PublicAPI/net9.0/PublicAPI.txt | 1 + .../Concurrency/AvaloniaScheduler.cs | 1 + .../Advanced/AsyncEnumerableSignal{T}.cs | 104 +- .../Advanced/BufferWitness.cs | 6 +- .../Advanced/ImmediateThrowSignal{T}.cs | 4 +- .../Advanced/ImmutableEmptySignal{T}.cs | 4 +- .../Advanced/SynchronizeWitness.cs | 3 + .../Concurrency/ScheduledItem.cs | 5 +- .../Core/CoreCompositeFormats.cs | 7 +- .../Signals/AsyncSignal.cs | 38 +- .../Signals/AwaitWitness.cs | 8 +- .../Signals/BehaviorSignalState{T}.cs | 25 +- .../Signals/BehaviorSignal{T}.cs | 19 +- .../Signals/Broadcaster{T}.cs | 16 +- .../Signals/CommandSignal{TResult}.cs | 168 +-- .../Signals/KeepSignal{T}.cs | 10 +- .../Signals/KeepWithSignal{T,TState}.cs | 5 +- .../Signals/MapSignal{TSource,TResult}.cs | 10 +- .../Signals/ObserverHandler.cs | 5 +- ...ProjectedReadOnlyState{TSource,TResult}.cs | 5 +- .../Signals/ReadOnlyState{T}.cs | 2 +- .../Signals/Signal{T}.cs | 54 +- .../SubscribeExtensions.cs | 5 +- .../ConcurrencyLimiter.cs | 13 +- .../Continuation.cs | 5 +- .../CurrentValueSubject.cs | 21 +- .../Internal/ConcurrencyRaceHelpers.cs | 17 +- .../Internal/IndexedSubscribeHelper.cs | 15 +- .../Internal/ObservableSubscribeExtensions.cs | 7 +- .../Internal/ReduceSinkState.cs | 12 +- .../Operators/BooleanReduceObservable.cs | 6 +- .../CatchAndReturnWithFactoryObservable.cs | 5 +- .../Operators/DoOnDisposeObservable.cs | 6 +- .../Operators/DropIfBusyObservable.cs | 61 +- .../Operators/FilterRegexObservable.cs | 2 +- .../FirstMatchFromCandidatesObservable.cs | 36 +- .../Operators/PartitionObservable.cs | 4 +- .../Operators/PropertyChangedObservable.cs | 9 +- .../ReplayLastOnSubscribeObservable.cs | 6 +- .../SelectAsyncConcurrentObservable.cs | 77 +- .../SelectAsyncSequentialObservable.cs | 67 +- .../Operators/SelectConstantObservable.cs | 5 +- .../Operators/SelectLatestAsyncObservable.cs | 81 +- .../Operators/SelectManyThenObservable.cs | 15 +- .../Operators/ShuffleObservable.cs | 27 +- .../Operators/SkipWhileNullObservable.cs | 8 +- .../Operators/SubscribeAsyncObservable.cs | 19 +- .../Operators/SynchronizeAsyncObservable.cs | 56 +- .../Operators/TakeUntilInclusiveObservable.cs | 5 +- .../TimerSinkState.cs | 8 +- .../ActivationExtensionsEmitter.cs | 7 +- .../CodeGeneration/ActivationSource.cs | 9 +- .../CodeGeneration/GeneratedNames.cs | 8 +- .../CodeGeneration/PooledStringBuilder.cs | 22 +- .../CodeGeneration/SourceFileWriter.cs | 6 +- .../CodeGeneration/StaticEventsEmitter.cs | 6 +- .../EventGenerator.cs | 31 +- .../GeneratorStepNames.cs | 6 +- .../Helpers/EventExtractor.cs | 40 +- .../Helpers/EventRequest.cs | 6 +- .../Helpers/InstanceTargetExtractor.cs | 20 +- .../Helpers/LanguageSupport.cs | 12 +- .../Helpers/ProviderResolver.cs | 8 +- .../Helpers/StaticTargetExtractor.cs | 21 +- .../Helpers/SymbolHelpers.cs | 42 +- .../Helpers/TargetCollections.cs | 7 +- .../Helpers/WellKnownTypes.cs | 6 +- .../Models/ActivationModel.cs | 6 +- .../Models/DiagnosticInfo.cs | 7 +- .../Models/EquatableArray.cs | 21 +- .../Models/EventModel.cs | 8 +- .../Models/InstanceTargetModel.cs | 6 +- .../Models/LocationInfo.cs | 7 +- .../Models/ObservableProvider.cs | 6 +- .../Models/StaticNamespaceModel.cs | 6 +- .../SequencerSchedulingExtensions.cs | 6 +- .../Concurrency/WasmScheduler.cs | 192 +-- .../Disposables/ContainerDisposable.cs | 26 +- .../LinqExtensions.ContainerDisposable.cs | 8 +- .../Platforms/android/HandlerSequencer.cs | 5 +- .../Platforms/apple/NSRunloopSequencer.cs | 5 +- .../Concurrency/DispatcherQueueSequencer.cs | 1 + .../net10.0-windows10.0.19041.0/PublicAPI.txt | 1 + .../net11.0-windows10.0.19041.0/PublicAPI.txt | 1 + .../net8.0-windows10.0.19041.0/PublicAPI.txt | 1 + .../net9.0-windows10.0.19041.0/PublicAPI.txt | 1 + .../Concurrency/DispatcherQueueSequencer.cs | 3 +- .../Properties/AssemblyInfo.cs | 7 + .../Concurrency/DispatcherSequencer.cs | 1 + .../PublicAPI/net10.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net11.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net9.0-windows/PublicAPI.txt | 1 + .../Concurrency/DispatcherSequencer.cs | 3 +- .../Properties/AssemblyInfo.cs | 7 + .../Advanced/DispatchSequencerState.cs | 34 +- .../Concurrency/CurrentThreadSequencer.cs | 44 +- .../Concurrency/ImmediateSequencer.cs | 18 +- .../Concurrency/Sequencer.cs | 11 +- .../SynchronizationContextSequencer.cs | 20 +- .../Concurrency/TaskPoolSequencer.cs | 22 +- .../Concurrency/ThreadPoolSequencer.cs | 120 +- .../Concurrency/VirtualTimeMessages.cs | 8 +- .../VirtualTimeState{TAbsolute,TRelative}.cs | 9 +- .../Concurrency/WasmSequencer.cs | 96 +- .../Platforms/android/HandlerSequencer.cs | 5 +- .../Platforms/apple/NSRunloopSequencer.cs | 5 +- .../AllocationProbe.cs | 14 +- .../ConnectableMulticastBenchmarks.cs | 5 +- .../FactoryStateTimerBenchmarks.cs | 6 +- .../OperatorCombineLatestBenchmarks.cs | 7 +- .../OperatorPassThroughBenchmarks.cs | 5 +- .../OperatorStatefulVariantBenchmarks.cs | 7 +- .../OperatorTakeBufferRecoverBenchmarks.cs | 5 +- ...eExtensionsComparisonBenchmarks.Helpers.cs | 4 +- .../SmokeBenchmarkRunner.cs | 19 +- .../SmokeParityValidator.cs | 7 +- .../TerminalAsyncVariantBenchmarks.cs | 5 +- .../TimerSlotBenchmarks.cs | 7 +- .../CorpusSize.cs | 1 - .../EventCorpus.cs | 20 +- .../EventGeneratorDriverBenchmarks.cs | 28 +- .../EventGeneratorGcProfileBenchmarks.cs | 12 +- .../GeneratorHarness.cs | 19 +- .../DisposableBagTests.cs | 3 +- .../DisposableSlotHelperTests.cs | 37 +- .../SequencerSchedulingTests.cs | 16 +- .../AsyncPrimitiveContractTests.cs | 46 +- .../AsyncRenameCoverageTests.cs | 78 +- .../AsyncSerialGateTests.cs | 46 +- .../CombineLatestArityTests.Arity10.cs | 6 +- .../CombineLatestArityTests.Arity11.cs | 6 +- .../CombineLatestArityTests.Arity12.cs | 6 +- .../CombineLatestArityTests.Arity13.cs | 6 +- .../CombineLatestArityTests.Arity14.cs | 6 +- .../CombineLatestArityTests.Arity15.cs | 6 +- .../CombineLatestArityTests.Arity16.cs | 6 +- .../CombineLatestArityTests.Arity9.cs | 9 +- ...ombineLatestArityTests.DisposedViaError.cs | 28 +- .../CombineLatestEnumerableInternalsTests.cs | 9 +- ...CombineLatestOperatorTests.Combines4To8.cs | 20 +- ...neLatestOperatorTests.EnumerableInitial.cs | 8 +- ...mbineLatestOperatorTests.EnumerableRest.cs | 48 +- .../CombineLatestOperatorTests.Misc.cs | 3 +- ...ombineLatestOperatorTests.PerArity2And3.cs | 20 +- .../CombineLatestOperatorTests.PerArity4.cs | 16 +- .../CombineLatestOperatorTests.PerArity5.cs | 20 +- .../CombineLatestOperatorTests.PerArity6.cs | 24 +- .../CombineLatestOperatorTests.PerArity7.cs | 28 +- ...eLatestOperatorTests.PerArity8Completes.cs | 16 +- ...bineLatestOperatorTests.PerArity8Errors.cs | 16 +- .../CombineLatestOperatorTests.cs | 3 - .../CombiningOperatorTests.Blend.cs | 82 +- ...ngOperatorTests.BlendEnumerableDisposal.cs | 174 +-- ...biningOperatorTests.BlendSignalDisposal.cs | 166 +-- .../CombiningOperatorTests.Chain.cs | 9 +- .../CombiningOperatorTests.Multicast.cs | 5 +- .../CombiningOperatorTests.OnDispose.cs | 6 +- ...CombiningOperatorTests.PrependStartWith.cs | 27 +- .../CombiningOperatorTests.Subscribe.cs | 16 +- .../CombiningOperatorTests.SwitchTo.cs | 11 +- .../CombiningOperatorTests.Zip.cs | 16 +- .../ConcurrentSignalBaseTests.cs | 74 +- .../CustomTaskScheduler.cs | 3 + .../DirectSource.cs | 6 +- .../DisposableAsyncSlotTests.cs | 18 + .../DisposableTests.SingleAssignment.cs | 87 ++ .../DisposableTests.cs | 162 +-- .../DisposalHelperTests.cs | 23 + .../ErrorHandlingOperatorTests.cs | 31 +- .../ExpireOperatorTests.cs | 196 +-- .../FactorySignalTests.cs | 85 +- .../FilteringOperatorTests.cs | 35 +- .../FireAndForgetHelperTests.cs | 50 + .../FlatMapCoordinatorTests.cs | 4 +- .../Internals/PooledDelaySourceTests.cs | 69 +- .../IntervalOperatorTests.cs | 42 +- .../ManualTimeProvider.cs | 113 ++ .../ObserveOnAsyncSignalTests.cs | 15 +- .../ObserverAsyncDisposeTests.cs | 210 ++- .../ParityHelpersFilterFusionsTests.cs | 37 +- .../ParityHelpersOperatorFusionsTests.cs | 234 +-- .../ParityOperatorTests.ObserveOn.cs | 115 ++ .../ParityOperatorTests.cs | 149 +- .../ReactiveUI.Primitives.Async.Tests.csproj | 4 - ...atestSignalAsyncStateObserverLeaseTests.cs | 68 + .../ResultAndInfrastructureTests.Multicast.cs | 72 +- .../SignalTests.BehaviorAndReplay.cs | 26 +- .../SignalTests.CreationOptions.cs | 6 +- .../SignalTests.MappedAndConcurrent.cs | 6 +- .../SignalTests.cs | 24 +- ...atestSignalAsyncStateObserverLeaseTests.cs | 83 ++ .../SyncLatestOperatorTests.HighArity.cs | 10 +- .../SyncLatestOperatorTests.LowArity.cs | 17 +- .../SyncLatestOperatorTests.cs | 18 +- ...keUntilOperatorTests.CompletionDelegate.cs | 4 +- ...akeUntilOperatorTests.DisposalAndErrors.cs | 79 +- .../TakeUntilOperatorTests.cs | 34 +- .../TaskSignalSubscriptionTests.cs | 13 +- .../TerminalOperatorTests.ElementSelection.cs | 507 +++++++ ...TerminalOperatorTests.OverloadShortcuts.cs | 5 +- .../TerminalOperatorTests.ReduceCoverage.cs | 2 +- .../TerminalOperatorTests.cs | 503 +------ .../TimeBasedOperatorTests.cs | 1251 +++++------------ ...TransformationOperatorTests.SideEffects.cs | 205 +++ .../TransformationOperatorTests.cs | 286 +--- .../UnhandledExceptionCapture.cs | 64 +- .../AvaloniaSchedulerTests.cs | 45 +- .../AvaloniaSchedulerTests.cs | 84 +- .../BlazorRendererSequencerTests.cs | 9 +- .../CoalescingDispatchSchedulerTests.cs | 59 +- .../BlazorRendererSequencerTests.cs | 9 +- .../SequencerSchedulingExtensionsTests.cs | 13 +- .../ContinuationTests.cs | 21 +- .../CurrentValueSubjectTests.MultiObserver.cs | 11 +- .../GlobalTestSetup.cs | 44 - .../Internal/FirstAsValueTaskHelperTests.cs | 5 +- ...ptionExtensionsTests.SchedulerOverloads.cs | 5 +- .../ObservableSubscriptionExtensionsTests.cs | 24 +- .../Operators/BooleanReduceObservableTests.cs | 4 +- ...atchAndReturnWithFactoryObservableTests.cs | 5 +- .../Operators/CatchIgnoreObservableTests.cs | 4 +- .../Operators/ConflateObservableTests.cs | 31 +- .../Operators/DetectStaleObservableTests.cs | 4 +- .../Operators/DoOnDisposeObservableTests.cs | 3 +- .../Operators/DropIfBusyObservableTests.cs | 54 +- .../FirstMatchFromCandidatesAsyncPathTests.cs | 33 +- .../Operators/ForEachObservableTests.cs | 9 +- .../Operators/HeartbeatObservableTests.cs | 4 +- .../Operators/MinMaxObservableTests.cs | 4 +- .../Operators/ObserveOnIfObservableTests.cs | 19 +- .../Operators/ObserveOnObservableTests.cs | 8 +- .../OperatorAfterTerminalGuardTests.cs | 14 +- ...artitionObservableTests.MultiSubscriber.cs | 4 +- .../Operators/PartitionObservableTests.cs | 7 +- .../PropertyChangedObservableTests.cs | 16 +- ...RetryAndThrottleAndFactoryOperatorTests.cs | 23 +- .../Operators/RunAllObservableTests.cs | 46 +- .../Operators/SampleLatestObservableTests.cs | 5 +- .../ScanWithInitialTests.Terminal.cs | 2 +- .../Operators/ScanWithInitialTests.cs | 7 +- .../ScheduledAndDebounceSyncOperatorTests.cs | 11 +- .../ScheduledSourceObservableTests.cs | 4 +- .../SelectAsyncConcurrentObservableTests.cs | 54 +- .../SelectAsyncSequentialObservableTests.cs | 44 +- .../SelectLatestAsyncObservableTests.cs | 103 +- .../SelectManyThenObservableTests.cs | 4 +- .../Operators/SimpleSyncOperatorTests.cs | 52 +- .../Operators/StartActionObservableTests.cs | 19 +- .../Operators/StartFuncObservableTests.cs | 19 +- .../SubscribeAsyncObservableTests.cs | 51 +- .../Operators/SyncDirectSource.cs | 9 +- .../SyncOperatorErrorForwardingTests.cs | 4 +- .../SynchronizeAsyncObservableTests.cs | 31 +- .../ThrottleAndWaitOperatorCoverageTests.cs | 4 +- .../ThrottleDistinctObservableTests.cs | 29 +- .../Operators/ThrottleFirstObservableTests.cs | 4 +- .../Operators/ThrottleObservableTests.cs | 32 +- .../ThrottleUntilTrueObservableTests.cs | 13 +- ...gActionObservableTests.SecondaryDispose.cs | 10 +- .../Operators/UsingActionObservableTests.cs | 32 +- .../UsingAndSwitchIfEmptyEdgeTests.cs | 26 +- .../Operators/UsingFuncObservableTests.cs | 9 +- .../Operators/WhileObservableTests.cs | 12 +- .../ReactiveExtensionsPortedTests.cs | 3 +- .../ReactiveExtensionsTests.FusedSelect.cs | 6 +- .../ReactiveExtensionsTests.Misc.cs | 31 +- .../ReactiveExtensionsTests.Retry.cs | 196 +-- .../ReactiveExtensionsTests.Scheduling.cs | 65 +- .../ReactiveExtensionsTests.SelectAsync.cs | 36 +- .../ReactiveExtensionsTests.Synchronize.cs | 69 +- .../ReactiveExtensionsTests.Throttle.cs | 43 +- .../ReactiveExtensionsTests.cs | 15 +- ...ctiveUI.Primitives.Extensions.Tests.csproj | 4 - .../MauiDispatcherSequencerTests.cs | 2 +- .../MauiDispatcherSequencerTests.cs | 8 +- .../EventGeneratorTests.Incremental.cs | 31 +- .../EventGeneratorTests.Nullability.cs | 11 +- .../EventGeneratorTests.StaticRequests.cs | 5 - .../EventGeneratorTests.cs | 2 +- .../DispatchWorkItemBaseTests.cs | 6 +- .../LinqExtensionsTests.cs | 6 +- ...ncerSchedulingExtensionsTests.Overloads.cs | 12 +- .../WasmSchedulerTests.cs | 222 ++- .../AsyncEnumerableSignalTests.cs | 67 +- .../AsyncSignalTests.cs | 128 +- ...rSignalTests.cs => BehaviorSignalTests.cs} | 41 +- .../ChooseSwitchSelectTests.cs | 7 +- .../CommandExecutionTests.cs | 17 +- .../CommandSignalTests.Results.cs | 60 + .../CommandSignalTests.cs | 164 +-- .../Common/AwaitableWitness.cs | 41 +- .../Common/CancellationTokenExtensions.cs | 28 +- .../Common/FirstInlineSequencer.cs | 12 +- .../Common/ManualSequencer.cs | 6 +- .../Common/ManualTaskScheduler.cs | 30 + .../Common/ManualThreadPool.cs | 49 + .../Common/OrderingWitness.cs | 6 +- .../Common/TaskNotificationObserver.cs | 41 + .../Common/TestPolling.cs | 33 - .../ConcurencyTests.cs | 153 -- .../ConnectableSignalTests.cs | 56 +- ...ests.cs => DispatchSequencerStateTests.cs} | 86 +- .../DisposableTests.cs | 10 +- .../EverySignalTests.cs | 262 +--- .../ExpireCoordinatorTests.cs | 19 +- .../FromAsyncTaskObservationTests.cs | 79 ++ .../LoopSignalTests.cs | 20 +- .../MergeCoordinatorTests.cs | 33 + .../OnErrorResumeNextSignalTests.cs | 2 +- .../PrioritySemaphoreSignalTests.cs | 385 ++--- .../ReactiveUI.Primitives.Tests.csproj | 4 - .../ReplaySignalTests.cs | 68 +- .../RxNamesTests.DynamicDataCompatibility.cs | 1 - .../RxNamesTests.Helpers.cs | 10 +- .../RxNamesTests.Scheduling.cs | 82 ++ .../RxNamesTests.cs | 81 +- .../SafeWitnessTests.cs | 7 +- .../ScheduledSignalTests.cs | 42 +- .../SequencerTests.Cancellation.cs | 113 ++ .../SequencerTests.Pools.cs | 224 ++- .../SequencerTests.VirtualTime.cs | 5 +- .../SequencerTests.WorkItems.cs | 84 +- .../SequencerTests.cs | 130 +- .../SignalCreateTests.cs | 37 +- .../SignalDeferTests.cs | 11 +- .../SignalFactoriesTests.Behavior.cs | 4 +- .../SignalFactoriesTests.Composition.cs | 6 +- .../SignalFactoriesTests.DefaultSequencer.cs | 41 +- .../SignalFactoriesTests.Scheduling.cs | 15 +- .../SignalFactoriesTests.cs | 43 +- .../SignalFromTaskTest.cs | 1218 ---------------- .../SignalOperatorMixinsTests.Advanced.cs | 7 +- .../SignalOperatorMixinsTests.Behavior.cs | 6 +- .../SignalOperatorMixinsTests.Delay.cs | 90 +- ...SignalOperatorMixinsTests.Deterministic.cs | 313 +---- ...lOperatorMixinsTests.PostTerminalGuards.cs | 9 +- .../SignalOperatorMixinsTests.Scheduling.cs | 281 ++++ .../SignalOperatorMixinsTests.cs | 13 +- ...torParityMixinsTests.PostTerminalGuards.cs | 7 +- ...overageTests.cs => SignalTests.Aliases.cs} | 19 +- .../SignalTests.cs | 9 +- .../SinkObserverTests.cs | 13 +- .../StateSignalTests.cs | 9 +- ...ts.cs => SwitchWitnessTests.Generation.cs} | 62 +- .../SwitchWitnessTests.cs | 131 +- .../SyncLatestTests.cs | 6 +- ...izeTests.cs => SynchronizeWitnessTests.cs} | 172 +-- .../TaskInstanceSignalTests.cs | 85 ++ .../TaskPoolSequencerTests.cs | 114 ++ .../TaskSignalTests.FromTask.cs | 239 ++++ .../TaskSignalTests.Lifecycle.cs | 162 +++ .../TaskSignalTests.Observation.cs | 194 +++ .../TaskSignalTests.cs | 5 +- .../WasmSequencerTests.cs | 236 ++-- .../WitnessTests.Helpers.cs | 155 ++ .../WitnessTests.cs | 198 +-- .../ControlSequencerTests.cs | 17 +- .../ControlSequencerTests.cs | 17 +- .../DispatcherQueueSequencerTests.cs | 40 +- .../DispatcherQueueSequencerTests.cs | 52 +- .../DispatcherSequencerTests.cs | 26 +- .../DispatcherSequencerTests.cs | 38 +- src/tests/Shared/AsyncTestHelpers.cs | 130 -- 583 files changed, 8191 insertions(+), 12175 deletions(-) create mode 100644 src/ReactiveUI.Primitives.WinUI/Properties/AssemblyInfo.cs create mode 100644 src/ReactiveUI.Primitives.Wpf/Properties/AssemblyInfo.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.SingleAssignment.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/DisposalHelperTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/FireAndForgetHelperTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ManualTimeProvider.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.ObserveOn.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ReplayLatestSignalAsyncStateObserverLeaseTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/StatelessReplayLatestSignalAsyncStateObserverLeaseTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ElementSelection.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.SideEffects.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Extensions.Tests/GlobalTestSetup.cs rename src/tests/ReactiveUI.Primitives.Tests/{BehaviourSignalTests.cs => BehaviorSignalTests.cs} (89%) create mode 100644 src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.Results.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/Common/ManualTaskScheduler.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/Common/ManualThreadPool.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/Common/TaskNotificationObserver.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Tests/Common/TestPolling.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs rename src/tests/ReactiveUI.Primitives.Tests/{DispatchSequencerBaseTests.cs => DispatchSequencerStateTests.cs} (87%) create mode 100644 src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/MergeCoordinatorTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs rename src/tests/ReactiveUI.Primitives.Tests/{SignalAliasCoverageTests.cs => SignalTests.Aliases.cs} (93%) rename src/tests/ReactiveUI.Primitives.Tests/{GenerationSafetyTests.cs => SwitchWitnessTests.Generation.cs} (64%) rename src/tests/ReactiveUI.Primitives.Tests/{SynchronizeTests.cs => SynchronizeWitnessTests.cs} (67%) create mode 100644 src/tests/ReactiveUI.Primitives.Tests/TaskInstanceSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Lifecycle.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Observation.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/WitnessTests.Helpers.cs delete mode 100644 src/tests/Shared/AsyncTestHelpers.cs diff --git a/src/Primitives.Async.Shared/AsyncContext.cs b/src/Primitives.Async.Shared/AsyncContext.cs index f18d77a0..67bc92bb 100644 --- a/src/Primitives.Async.Shared/AsyncContext.cs +++ b/src/Primitives.Async.Shared/AsyncContext.cs @@ -12,15 +12,8 @@ namespace ReactiveUI.Primitives.Async.Reactive; namespace ReactiveUI.Primitives.Async; #endif -/// -/// Represents an asynchronous execution context that encapsulates a specific SynchronizationContext or TaskScheduler -/// for controlling the scheduling of asynchronous operations. -/// -/// Use AsyncContext to capture and restore a particular synchronization or task scheduling environment -/// when running asynchronous code. This is useful for ensuring that continuations or asynchronous callbacks execute on -/// a desired context, such as a UI thread or a custom scheduler. An AsyncContext can be created from a -/// SynchronizationContext, TaskScheduler, or ISequencer. The Default context represents the absence of a specific -/// synchronization or scheduling context, and typically corresponds to the default task scheduler. +/// Represents an asynchronous execution context that encapsulates a specific SynchronizationContext or TaskScheduler for controlling the scheduling of asynchronous operations. +/// Contexts wrap a synchronization context, task scheduler, or sequencer. Default uses the default task scheduler. [System.Diagnostics.DebuggerDisplay( "AsyncContext: SynchronizationContext = {SynchronizationContext}, TaskScheduler = {TaskScheduler}, Sequencer = {Sequencer}")] public sealed record AsyncContext @@ -31,15 +24,10 @@ private AsyncContext() } /// Gets the default instance of the AsyncContext class. - /// Use this property to access a shared, default AsyncContext instance when a custom context is - /// not required. public static AsyncContext Default { get; } = new(); /// Gets the synchronization context to use for marshaling callbacks and continuations. - /// If this property is set, callbacks and continuations will be posted to the specified - /// synchronization context. If null, the default context is used, which may result in execution on a thread pool - /// thread. This property is typically used to ensure that asynchronous operations resume on a specific thread or - /// context, such as a UI thread. + /// A specified synchronization context receives posted continuations; otherwise the task scheduler determines execution. public SynchronizationContext? SynchronizationContext { get; init; } /// Gets the task scheduler to use for scheduling tasks, or null to use the default scheduler. @@ -57,9 +45,6 @@ private AsyncContext() /// The SynchronizationContext to associate with the AsyncContext. Cannot be null. /// An AsyncContext instance configured to use the provided SynchronizationContext. /// Thrown if synchronizationContext is null. - /// The returned AsyncContext will have its TaskScheduler property set to null. Use this method - /// when you want to control asynchronous execution using a specific SynchronizationContext, such as for UI thread - /// synchronization. public static AsyncContext From(SynchronizationContext synchronizationContext) { ArgumentExceptionHelper.ThrowIfNull(synchronizationContext); @@ -98,12 +83,6 @@ public static AsyncContext From(ISequencer scheduler) /// An representing the current asynchronous context. If a is present, it is used; otherwise, the current is /// used. - /// - /// 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. - /// - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static AsyncContext GetCurrent() { var currentSc = SynchronizationContext.Current; @@ -118,19 +97,12 @@ public static AsyncContext GetCurrent() public AsyncContextSwitcherAwaitable SwitchContextAsync(bool forceYielding, CancellationToken cancellationToken) => new(this, forceYielding, cancellationToken); - /// - /// Provides an awaitable that switches execution to a specified asynchronous context, optionally forcing a yield - /// and supporting cancellation. - /// + /// Provides an awaitable that switches execution to a specified asynchronous context, optionally forcing a yield and supporting cancellation. /// The asynchronous context to which execution should be switched when awaited. /// true to always yield execution even if already in the target context; otherwise, false to avoid yielding if /// already in the specified context. /// A cancellation token that can be used to cancel the await operation before the continuation is scheduled. - /// Use this struct to ensure that code after an await resumes on a specific asynchronous - /// context, such as a particular SynchronizationContext or TaskScheduler. If cancellation is requested before the - /// continuation is scheduled, the continuation is invoked immediately and an OperationCanceledException will be - /// thrown when GetResult is called. This type is intended for advanced scenarios where precise control over - /// asynchronous context switching is required. + /// Cancellation invokes the continuation immediately; GetResult then throws OperationCanceledException. [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable( AsyncContext AsyncContext, @@ -140,26 +112,18 @@ public readonly record struct AsyncContextSwitcherAwaitable( /// Gets a value indicating whether the asynchronous operation has completed in the current context. public bool IsCompleted => !ForceYielding && AsyncContext.IsSameAsCurrentAsyncContext(); - /// Checks whether the associated cancellation token has had cancellation requested and throws an exception if so. - /// This method is typically used to observe cancellation requests and respond by - /// throwing an OperationCanceledException if cancellation has been signaled. If cancellation has not been - /// requested, the method returns normally. + /// Throws if cancellation was requested. + /// Cancellation was requested. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void GetResult() => CancellationToken.ThrowIfCancellationRequested(); - /// - /// Returns an awaiter for this AsyncContextSwitcherAwaitable instance, enabling use of the await keyword to - /// asynchronously switch execution context. - /// + /// Returns an awaiter for this AsyncContextSwitcherAwaitable instance, enabling use of the await keyword to asynchronously switch execution context. /// An awaiter that can be used to await this instance and perform an asynchronous context switch. public AsyncContextSwitcherAwaitable GetAwaiter() => this; /// Schedules the specified continuation action to be invoked when the operation has completed. /// The action to execute when the operation is complete. Cannot be null. - /// If a synchronization context is available, the continuation is posted to it; - /// otherwise, the continuation is scheduled on the associated task scheduler or the default task scheduler. If - /// the operation has already been canceled, the continuation is invoked immediately on the current - /// thread. + /// Continuations use the synchronization context or task scheduler; cancellation invokes them immediately on the current thread. public void OnCompleted(Action continuation) { ArgumentExceptionHelper.ThrowIfNull(continuation); @@ -195,9 +159,7 @@ public void OnCompleted(Action continuation) return; } - // Fast path for the default scheduler: bypass Task.Factory.StartNew (which allocates a - // Task per call) and queue the continuation directly to the threadpool. This is the - // path Yield takes by default, so the saving lands on the operator's hot path. + // Queue directly to avoid allocating a Task for each continuation. if (ts is null || ts == TaskScheduler.Default) { _ = ThreadPool.UnsafeQueueUserWorkItem(static c => ((Action)c!).Invoke(), continuation); @@ -214,12 +176,8 @@ private sealed class ContinuationWorkItem(Action continuation) : IWorkItem } } - /// Provides a custom TaskScheduler that schedules tasks using the specified IScheduler. + /// Routes task execution through the supplied sequencer. /// The ISequencer used to schedule and execute tasks. Cannot be null. - /// This TaskScheduler enables integration of Task-based asynchronous code with reactive or - /// custom scheduling strategies by delegating task execution to the provided ISequencer. Tasks scheduled through - /// this TaskScheduler will be executed according to the policies of the specified ISequencer. This class is - /// intended for advanced scenarios where control over task scheduling is required. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "CA1812:Avoid uninstantiated internal classes", @@ -230,12 +188,12 @@ internal sealed class SequencerTaskScheduler(ISequencer scheduler) : TaskSchedul /// Gets the sequencer used by this task-scheduler adapter. internal ISequencer Sequencer => scheduler; - /// Internal accessor for the protected override; used only by the test assembly. + /// Returns the adapter's scheduled-task enumeration. /// The result of the protected implementation. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal IEnumerable? GetScheduledTasksForTesting() => GetScheduledTasks(); - /// Internal accessor for the protected override; used only by the test assembly. + /// Attempts inline execution through the adapter. /// The task to attempt to execute inline. /// Whether the task was previously queued. /// The result of the protected implementation. diff --git a/src/Primitives.Async.Shared/Mixins/AsyncContextExtensions.cs b/src/Primitives.Async.Shared/Mixins/AsyncContextExtensions.cs index 4d83c7cc..3ea4d942 100644 --- a/src/Primitives.Async.Shared/Mixins/AsyncContextExtensions.cs +++ b/src/Primitives.Async.Shared/Mixins/AsyncContextExtensions.cs @@ -18,9 +18,7 @@ public static class AsyncContextExtensions /// Determines whether the specified represents the current asynchronous context. /// if the specified matches the current or ; otherwise, . - /// This method compares the , , or sequencer adapter - /// of the provided with the current context to determine equivalence. Use this method to - /// check if code is executing within the intended asynchronous environment. + /// A sequencer matches only when the current task scheduler wraps that same sequencer instance. public bool IsSameAsCurrentAsyncContext() { ArgumentExceptionHelper.ThrowIfNull(context); diff --git a/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs b/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs index fdfc2444..9fcba034 100644 --- a/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs +++ b/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs @@ -24,10 +24,7 @@ public static partial class SignalAsyncReactiveExtensions /// to cancel the operation. extension(Func factory) { - /// - /// Creates an asynchronous observable sequence that executes the specified factory function and signals completion - /// when the operation finishes. - /// + /// Creates an asynchronous observable sequence that executes the specified factory function and signals completion when the operation finishes. /// An observable sequence that emits a single value of when the factory function completes, /// followed by a completion notification. /// Thrown if is . @@ -37,7 +34,7 @@ public static partial class SignalAsyncReactiveExtensions [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "Names the asynchronous observable factory.")] public IObservableAsync FromAsync() { ArgumentExceptionHelper.ThrowIfNull(factory); diff --git a/src/Primitives.Async.Shared/Observables/ToAsyncSignal.Reactive.cs b/src/Primitives.Async.Shared/Observables/ToAsyncSignal.Reactive.cs index 2905cb29..dce487a3 100644 --- a/src/Primitives.Async.Shared/Observables/ToAsyncSignal.Reactive.cs +++ b/src/Primitives.Async.Shared/Observables/ToAsyncSignal.Reactive.cs @@ -27,14 +27,12 @@ public static partial class SignalAsyncReactiveExtensions /// Converts the specified task into an asynchronous observable sequence that signals completion when the task finishes. /// An asynchronous observable sequence that emits a single value when the task completes successfully, followed by /// a completion notification. - /// The returned observable emits a single unit value upon task completion and then signals - /// completion. If the task is canceled or fails, the observable will propagate the corresponding error. This method - /// is useful for integrating task-based operations into observable workflows. + /// Task failure or cancellation terminates the sequence with the corresponding error. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", - Justification = "This is an existing method")] + Justification = "Names the asynchronous observable conversion.")] public IObservableAsync ToAsyncSignal() => new TaskToAsyncSignal(task); } } diff --git a/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs b/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs index b102a816..39136346 100644 --- a/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs +++ b/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs @@ -34,9 +34,7 @@ internal sealed class ContextSwitchWitness( AsyncContext asyncContext, bool forceYielding) : WitnessAsync { - /// 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. + /// Switches to the target context before forwarding the value. /// The value to forward. /// The cancellation token. /// A task that completes after the context switch and downstream forward. @@ -46,7 +44,7 @@ internal async ValueTask ForwardAfterContextSwitchAsync(T value, CancellationTok await observer.OnNextAsync(value, cancellationToken).ConfigureAwait(false); } - /// Slow path: switch to the target context then forward the error. Exposed as for direct unit testing. + /// Switches to the target context before forwarding the error. /// The error to forward. /// The cancellation token. /// A task that completes after the context switch and downstream forward. @@ -58,7 +56,7 @@ internal async ValueTask ForwardErrorAfterContextSwitchAsync( await observer.OnErrorResumeAsync(error, cancellationToken).ConfigureAwait(false); } - /// Slow path: switch to the target context then forward completion. Exposed as for direct unit testing. + /// Switches to the target context before forwarding completion. /// The completion result. /// A task that completes after the context switch and downstream forward. internal async ValueTask ForwardCompletionAfterContextSwitchAsync(Result result) @@ -69,8 +67,7 @@ internal async ValueTask ForwardCompletionAfterContextSwitchAsync(Result result) /// protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) => - // Fast path: already on the target context and no forced yield — skip the awaitable - // dance entirely and forward synchronously. + // A matching context needs no switch unless yielding is forced. !forceYielding && asyncContext.IsSameAsCurrentAsyncContext() ? observer.OnNextAsync(value, cancellationToken) : ForwardAfterContextSwitchAsync(value, cancellationToken); diff --git a/src/Primitives.Async.Shared/Operators/WitnessOn.cs b/src/Primitives.Async.Shared/Operators/WitnessOn.cs index ca885dce..32d900fc 100644 --- a/src/Primitives.Async.Shared/Operators/WitnessOn.cs +++ b/src/Primitives.Async.Shared/Operators/WitnessOn.cs @@ -15,9 +15,6 @@ namespace ReactiveUI.Primitives.Async; #endif /// Provides extension methods for controlling the execution context of asynchronous observable sequences. -/// The methods in this class allow you to specify the context on which observer callbacks are invoked -/// for an asynchronous observable sequence. This is useful for ensuring that notifications are delivered on a -/// particular synchronization or task context, such as a UI thread or a custom scheduler. public static partial class SignalAsyncReactiveExtensions { /// Context-switching operators that control where observer callbacks are invoked for an observable source sequence. @@ -79,9 +76,6 @@ public IObservableAsync WitnessOn(TaskScheduler taskScheduler) => /// The scheduler on which to observe and deliver notifications to observers. Cannot be null. /// true to force yielding to the scheduler even if already on the target context; otherwise, false. /// An observable sequence whose notifications are delivered on the specified scheduler. - /// Use this method to control the context (such as a UI thread or a specific task - /// scheduler) on which observers receive notifications. This is useful for ensuring thread safety or updating - /// UI elements from observable sequences. public IObservableAsync WitnessOn(ISequencer scheduler, bool forceYielding) { var asyncContext = AsyncContext.From(scheduler); diff --git a/src/Primitives.Async.Shared/Operators/Yield.cs b/src/Primitives.Async.Shared/Operators/Yield.cs index 109dff9b..f0a0ccd8 100644 --- a/src/Primitives.Async.Shared/Operators/Yield.cs +++ b/src/Primitives.Async.Shared/Operators/Yield.cs @@ -8,9 +8,6 @@ namespace ReactiveUI.Primitives.Async.Reactive; namespace ReactiveUI.Primitives.Async; #endif /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static methods that extend the functionality of asynchronous -/// observables, enabling advanced composition and control over asynchronous data streams. These methods are intended -/// for use with types that implement asynchronous observer patterns. public static partial class SignalAsyncReactiveExtensions { /// Scheduler-yielding operators for an observable source sequence. @@ -18,14 +15,9 @@ public static partial class SignalAsyncReactiveExtensions /// The source observable sequence to yield from. extension(IObservableAsync source) { - /// - /// Returns an observable sequence that yields control to the current thread's scheduler before emitting items from - /// the source sequence. - /// + /// Returns an observable sequence that yields control to the current thread's scheduler before emitting items from the source sequence. /// An observable sequence that emits the same elements as the source, but yields control to the scheduler before /// each emission. - /// This method can be used to ensure that the source sequence's emissions are scheduled - /// asynchronously, which may help avoid stack overflows or improve responsiveness in certain scenarios. public IObservableAsync Yield() { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs b/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs index 9e168449..039ab19e 100644 --- a/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs +++ b/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs @@ -159,11 +159,7 @@ protected virtual void Dispose(bool disposing) _subscriptions.Dispose(); } - /// - /// Runs a callback through the renderer and routes failures into Blazor's error handling - /// () so error boundaries observe them - /// instead of the fault being lost with the discarded task. - /// + /// Runs a renderer callback and forwards failures to Blazor error boundaries. /// Callback to run on the renderer dispatcher. /// A task that completes when the callback (or its failure dispatch) has finished. private async Task InvokeGuardedAsync(Action callback) diff --git a/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs b/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs index 11581b65..74c18522 100644 --- a/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs +++ b/src/Primitives.Extensions.Shared/Internal/InvalidOperationExceptionHelper.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Throws when constructor-supplied operator state is missing at the -/// point it is consumed, naming the captured member and the calling member in the message. -/// +/// Rejects missing operator state with the member and operation names. [ExcludeFromCodeCoverage] internal static class InvalidOperationExceptionHelper { @@ -34,10 +31,7 @@ internal static void ThrowIfNull( $"'{memberName}' was not supplied to '{operation}'."); } - /// - /// Returns when non-null and otherwise throws, shaped for use in a primary - /// constructor initializer. - /// + /// Returns the argument or throws if it is null. /// The type of the argument. /// The argument to validate. /// The validated argument's name, captured from the expression. @@ -60,10 +54,7 @@ internal static T Check( $"'{memberName}' was not supplied to '{operation}'."); } - /// - /// Returns when it is neither null nor empty and otherwise throws, shaped for use - /// in a primary constructor initializer. - /// + /// Returns the argument or throws if it is null or empty. /// The argument to validate. /// The validated argument's name, captured from the expression. /// The calling member's name. diff --git a/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs b/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs index 7271810a..c6dd7137 100644 --- a/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs +++ b/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs @@ -11,12 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Internal; namespace ReactiveUI.Primitives.Extensions.Internal; #endif -/// -/// Queue-and-single-drain marshaller composed by the synchronous scheduler-marshalling operator sinks -/// (ObserveOn, Conflate): notifications are enqueued under the sink's gate and one drain pass is -/// scheduled per burst rather than one scheduled action per item. The drain callback is capture-free, taking -/// the sink through as an . -/// +/// Queues notifications under the sink's gate and schedules one drain per burst. /// The element type carried by notifications. /// The scheduler each drain pass runs on. /// The sink whose the scheduled pass invokes. @@ -90,7 +85,7 @@ internal bool TryDequeue(out Notification notification) } } - /// Marks the sink terminated and drops any still-queued notifications. Locks the gate. + /// Marks the sink done, clears queued notifications, and returns its upstream handle; the caller must hold the gate. internal void Terminate() { lock (_gate) @@ -100,12 +95,10 @@ internal void Terminate() } } - /// Marks the sink terminated without clearing the queue. Caller must hold the gate; - /// the still-queued notifications are abandoned because checks the done flag first. + /// Marks the sink done, clears queued notifications, and returns its upstream handle; the caller must hold the gate. internal void MarkDoneLocked() => _done = true; - /// Begins disposal under the gate, returning the upstream subscription so the caller disposes it - /// outside the gate, or when the sink has terminated. + /// Begins disposal under the gate, returning the upstream subscription so the caller disposes it outside the gate, or when the sink has terminated. /// The upstream subscription to dispose outside the gate, or . internal IDisposable? BeginDispose() { @@ -115,9 +108,7 @@ internal void Terminate() } } - /// Marks the sink done, clears the queue, and returns the upstream subscription for disposal outside - /// the gate. Caller must hold the gate and have confirmed is , which - /// lets it retire its own scheduled-work slot atomically with the done transition. + /// Marks the sink done, clears queued notifications, and returns its upstream handle; the caller must hold the gate. /// The upstream subscription to dispose outside the gate, or . internal IDisposable? BeginDisposeLocked() { diff --git a/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs b/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs index 48ea0223..071cfb63 100644 --- a/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs +++ b/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs @@ -11,10 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive; namespace ReactiveUI.Primitives.Extensions; #endif -/// -/// Provides extension methods that subscribe to a sequence and block the calling thread until it produces a -/// value or terminates, returning the emitted value or the captured error. -/// +/// Provides extension methods that subscribe to a sequence and block the calling thread until it produces a value or terminates, returning the emitted value or the captured error. public static class ObservableSubscriptionExtensions { /// The default timeout used by the WaitFor* helpers when no override is supplied. @@ -238,10 +235,7 @@ private static void WaitForCompletionCore(IObservable source, ISequencer return sink.Error; } - /// - /// Subscribes to on , or - /// inline when no scheduler is supplied. - /// + /// Subscribes to on , or inline when no scheduler is supplied. /// The type of the elements in . /// The observable to subscribe to. /// The observer to receive notifications from the observable. diff --git a/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs b/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs index 25ad98c5..405cadbb 100644 --- a/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Projection operator that emits for every source element without allocating a -/// projection closure. -/// +/// Projection operator that emits for every source element without allocating a projection closure. /// The element type of the source observable (ignored). /// The source observable whose values are ignored. internal sealed class AsSignalObservable(IObservable source) : IObservable diff --git a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs index 42a334d3..9277f051 100644 --- a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Buffers elements and emits them when the stream has been idle for a specified duration. Backs both -/// the BufferUntilIdle and BufferUntilInactive public operators. -/// +/// Buffers elements and emits them when the stream has been idle for a specified duration. Backs both the BufferUntilIdle and BufferUntilInactive public operators. /// The type of elements in the source sequence. /// The source observable. /// The duration of inactivity required to flush the buffer. diff --git a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs index 3960cbf3..088a5359 100644 --- a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs @@ -33,11 +33,7 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// - /// Single observer that both marshals upstream notifications onto the scheduler thread, through the - /// queue and scheduled drain, and applies the conflate time-window - /// throttle to each notification. - /// + /// Delivers notifications on the scheduler and limits value emissions to the conflate interval. internal sealed class ConflateSink : IObserver, IDisposable, IDrainTarget { /// The downstream observer. @@ -61,8 +57,7 @@ internal sealed class ConflateSink : IObserver, IDisposable, IDrainTarget /// Wall-clock timestamp of the last emission forwarded downstream. private DateTimeOffset _lastUpdateTime = DateTimeOffset.MinValue; - /// Set to when an upstream OnCompleted is queued but a deferred - /// emission is still pending; the completion fires after that emission lands. + /// Set to when an upstream OnCompleted is queued but a deferred emission is still pending; the completion fires after that emission lands. private bool _completionRequested; /// Initializes a new instance of the class. @@ -147,18 +142,15 @@ void IDrainTarget.Drain() [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void AttachSourceSubscription(IDisposable subscription) => _state.Attach(subscription); - /// Applies the throttle-window decision to a dequeued value, emitting it inline or scheduling a - /// deferred emission at the end of the window. + /// Emits a dequeued value immediately or defers it until the conflate interval ends. /// The value to forward. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void ProcessNext(T value) + internal void ProcessNext(T value) { var currentUpdateTime = _scheduler.Now; bool scheduleRequired; lock (_gate) { - // Race-only: a concurrent dispose can flip the done flag between the drain dequeue and this gate. if (_state.Done) { return; @@ -182,55 +174,12 @@ private void ProcessNext(T value) } } - /// Schedules a deferred emission of at the end of the throttle window, forwarding a pending completion once it lands. - /// The value to emit when the window elapses. - private void ScheduleDeferredEmission(T value) => - _updateScheduled.Disposable = _scheduler.Schedule( - (Sink: this, Value: value), - _lastUpdateTime + _minimumUpdatePeriod, - static (_, state) => - { - state.Sink.EmitDeferred(state.Value); - return EmptyDisposable.Instance; - }); - - /// Emits a deferred value and forwards a pending completion once the value lands. - /// The deferred value. - private void EmitDeferred(T value) - { - _downstream.OnNext(value); - - lock (_gate) - { - _lastUpdateTime = _scheduler.Now; - _updateScheduled.Disposable = null; - if (_completionRequested) - { - _state.MarkDoneLocked(); - _downstream.OnCompleted(); - } - } - } - - /// Emits immediately and records the emission time. - /// The value to emit. - private void EmitInline(T value) - { - _downstream.OnNext(value); - lock (_gate) - { - _lastUpdateTime = _scheduler.Now; - } - } - /// Forwards an error to downstream and terminates the sink. /// The error to forward. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void ForwardError(Exception error) + internal void ForwardError(Exception error) { lock (_gate) { - // Race-only: a concurrent dispose can flip the done flag between the drain dequeue and this gate. if (_state.Done) { return; @@ -244,12 +193,10 @@ private void ForwardError(Exception error) } /// Forwards completion, deferring it when a throttled emission is scheduled. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void ForwardCompleted() + internal void ForwardCompleted() { lock (_gate) { - // Race-only: a concurrent dispose can flip the done flag between the drain dequeue and this gate. if (_state.Done) { return; @@ -266,5 +213,46 @@ private void ForwardCompleted() _downstream.OnCompleted(); } + + /// Schedules a value for the end of the conflate interval. + /// The value to emit when the interval elapses. + private void ScheduleDeferredEmission(T value) => + _updateScheduled.Disposable = _scheduler.Schedule( + (Sink: this, Value: value), + _lastUpdateTime + _minimumUpdatePeriod, + static (_, state) => + { + state.Sink.EmitDeferred(state.Value); + return EmptyDisposable.Instance; + }); + + /// Emits a deferred value before forwarding any pending completion. + /// The deferred value. + private void EmitDeferred(T value) + { + _downstream.OnNext(value); + + lock (_gate) + { + _lastUpdateTime = _scheduler.Now; + _updateScheduled.Disposable = null; + if (_completionRequested) + { + _state.MarkDoneLocked(); + _downstream.OnCompleted(); + } + } + } + + /// Emits a value immediately and records the emission time. + /// The value to emit. + private void EmitInline(T value) + { + _downstream.OnNext(value); + lock (_gate) + { + _lastUpdateTime = _scheduler.Now; + } + } } } diff --git a/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs b/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs index 529016f3..3e6cf57e 100644 --- a/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Flattening operator that subscribes to an upstream sequence of batches and emits -/// each contained element individually. -/// +/// Flattening operator that subscribes to an upstream sequence of batches and emits each contained element individually. /// The element type emitted to the downstream observer. /// The source observable of enumerables. /// An optional scheduler used to marshal source notifications onto. diff --git a/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs b/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs index b8a06fde..c41cbcbf 100644 --- a/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Factory operator that emits each element of an to a single subscriber and then -/// completes, optionally marshalling the emission through a scheduler. -/// +/// Factory operator that emits each element of an to a single subscriber and then completes, optionally marshalling the emission through a scheduler. /// The element type emitted to the downstream observer. /// The source enumerable whose elements are pumped on subscription. /// An optional scheduler used to dispatch the pump. diff --git a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs index f17f3740..e274fa86 100644 --- a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs @@ -10,11 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Marshals every source notification onto the supplied , preserving order: notifications -/// are enqueued in and one drain pass is scheduled per burst rather than one -/// scheduled action per item. -/// +/// Delivers source notifications in order on the supplied sequencer, scheduling one drain per burst. /// The element type of the source sequence. /// The source observable. /// The scheduler every notification is delivered on. @@ -39,11 +35,7 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// - /// Single observer that queues upstream notifications and drains them on the scheduler thread in - /// FIFO order. Terminal notifications travel through the same queue so they never overtake - /// still-queued values. - /// + /// Queues values and terminal notifications together for ordered delivery on the scheduler. private sealed class ObserveOnSink : IObserver, IDisposable, IDrainTarget { /// The downstream observer. diff --git a/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs b/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs index 2e67051b..c10ff22d 100644 --- a/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs +++ b/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs @@ -8,10 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Bundled retry configuration for : retry count, delay schedule, -/// scheduler, and an optional error sink. -/// +/// Bundled retry configuration for : retry count, delay schedule, scheduler, and an optional error sink. /// Maximum number of retries. /// Delay before the first retry. /// Multiplier applied to the delay per retry attempt. diff --git a/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs b/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs index 05d4b096..e7b05b55 100644 --- a/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs @@ -10,12 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Runs a list of one-shot observables sequentially, -/// ignoring emitted values, and emits a single when all -/// have completed. If the list is empty, emits immediately. -/// Errors from any observable propagate to the downstream observer. -/// +/// Runs sources sequentially, ignores their values, and emits RxVoid on completion. Empty input completes immediately; source errors propagate. /// The list of one-shot observables to run in order. internal sealed class RunAllObservable(IReadOnlyList> sources) : IObservable { @@ -36,14 +31,10 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// - /// Stateful observer that walks the source list sequentially: the sink subscribes itself to each source, and its - /// own sets a per-iteration flag the surrounding loop reads to - /// decide whether to advance. - /// + /// Advances through sources as each subscription completes, handling synchronous completion without recursive subscription. /// The downstream observer. /// The source list to walk. - private sealed class Sink( + internal sealed class Sink( IObserver downstream, IReadOnlyList> sources) : IObserver, IDisposable { @@ -59,9 +50,7 @@ private sealed class Sink( /// Guards against re-entrant calls. private bool _looping; - /// Per-iteration latch (0 = pending, 1 = terminated), set by when a source - /// terminates synchronously during Subscribe and read by the surrounding loop in - /// . + /// Records synchronous source termination during subscription. private int _iterationTerminated; /// @@ -106,10 +95,7 @@ public void Dispose() Interlocked.Exchange(ref _currentSubscription, null)?.Dispose(); } - /// - /// Subscribes to the next source, or emits RxVoid and completes if all are done. - /// Iteratively loops on synchronous completion to avoid recursive stack growth. - /// + /// Advances through synchronously completing sources without recursion, then completes when all sources are done. internal void RunNext() { _looping = true; @@ -138,10 +124,8 @@ internal void RunNext() } /// Emits the terminal and completes once all sources have run. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void CompleteRun() + internal void CompleteRun() { - // Race-only: a concurrent dispose can latch the done flag between the loop exit and this call. if (Interlocked.Exchange(ref _done, 1) != 0) { return; diff --git a/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs b/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs index 8d8832d7..48edde8c 100644 --- a/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs @@ -10,16 +10,8 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Source-driven scheduled observable: for every upstream value, schedules a callback on the supplied -/// that applies an optional side-effect and/or -/// transform before forwarding the value downstream. -/// +/// Schedules source values without forwarding errors or completion. /// The element type of the source observable. -/// -/// Only is forwarded: source errors and completion never reach the downstream -/// observer, so a subscriber sees no terminal notification. -/// internal sealed class ScheduledSourceObservable : IObservable { /// The upstream observable. @@ -48,10 +40,7 @@ public IDisposable Subscribe(IObserver observer) return _source.Subscribe(sink); } - /// - /// Carries the per-emission state by value into the scheduled callback so the scheduler lambda captures - /// nothing. - /// + /// Carries the per-emission state by value into the scheduled callback so the scheduler lambda captures nothing. /// The downstream observer. /// The value to emit. /// The optional transform. diff --git a/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs b/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs index d5a61fa6..3c3e4e14 100644 --- a/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ScheduledValueObservable.cs @@ -10,16 +10,8 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Single-value scheduled observable: on subscription, schedules a callback on the supplied -/// that applies an optional side-effect and/or an optional -/// transform to the captured value and calls once. -/// +/// Schedules a value without a terminal notification; disposal ends the subscription. /// The value type emitted to the downstream observer. -/// -/// The sequence never completes on its own: no terminal notification is sent, so the subscriber ends it by -/// disposing the subscription. -/// internal sealed class ScheduledValueObservable : IObservable { /// The value to emit. diff --git a/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs b/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs index 180f2b12..031020ca 100644 --- a/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Runs the function once — inline when no scheduler is supplied, otherwise on the scheduler — -/// emits the result, then completes. -/// +/// Runs the function once — inline when no scheduler is supplied, otherwise on the scheduler — emits the result, then completes. /// Result type. /// Function to run. /// Optional scheduler; null runs inline. diff --git a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs index fe782db6..4681e65b 100644 --- a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs @@ -34,10 +34,7 @@ internal static IObservable Get(TimeSpan timeSpan, ISequencer schedule return _timerList.GetOrAdd((timeSpan, scheduler), _create); } - /// - /// Connectable timer that fans each tick out to its observers: the tick path reads a swap-on-write observer - /// array lock-free, while subscribe and unsubscribe take the gate and publish a fresh array. - /// + /// Broadcasts ticks through immutable observer snapshots; subscription changes publish a new snapshot under the gate. /// The period. /// The scheduler. private sealed class SharedTimer(TimeSpan timeSpan, ISequencer scheduler) : IObservable @@ -48,10 +45,7 @@ private sealed class SharedTimer(TimeSpan timeSpan, ISequencer scheduler) : IObs /// The gate for subscribe/unsubscribe writes. private readonly Lock _gate = new(); - /// - /// Snapshot of active observers, replaced rather than mutated on subscribe and unsubscribe under - /// so the tick path can read it without the lock. - /// + /// Snapshot of active observers, replaced rather than mutated on subscribe and unsubscribe under so the tick path can read it without the lock. private IObserver[] _observers = _emptyObservers; /// The active timer subscription, or when no observers are attached. diff --git a/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs b/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs index 6a4fcecf..17a3ee90 100644 --- a/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs @@ -37,7 +37,7 @@ public IDisposable Subscribe(IObserver observer) /// The observer to forward elements to. /// The throttle duration. /// The scheduler to use for timing. - private sealed class ThrottleDistinctSink( + internal sealed class ThrottleDistinctSink( IObserver downstream, TimeSpan throttle, ISequencer scheduler) : IObserver, IDisposable @@ -111,13 +111,11 @@ public void Dispose() } /// Emits the last received value when it differs from the last emitted value. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void Emit() + internal void Emit() { T? toEmit; lock (_gate) { - // Race-only: reachable when this scheduled callback overlaps Dispose or a terminal notification. if (_state.Done || !_hasLastReceived) { return; diff --git a/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs b/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs index c54836b6..f0642099 100644 --- a/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ThrottleObservable.cs @@ -10,13 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Classic throttle (debounce) operator. Emits a value only after -/// has elapsed without any new emission from the -/// source. Each new upstream OnNext cancels the pending emission and -/// schedules a new one. Provides the equivalent of Rx's -/// Observable.Throttle without depending on System.Reactive.Linq. -/// +/// Emits the latest value after the configured quiet period. /// The element type of the source observable. /// The source observable. /// The quiescence duration required before emission. @@ -42,7 +36,7 @@ public IDisposable Subscribe(IObserver observer) /// The downstream observer. /// The quiescence duration. /// The scheduler used to time emissions. - private sealed class ThrottleSink( + internal sealed class ThrottleSink( IObserver downstream, TimeSpan dueTime, ISequencer scheduler) : IObserver, IDisposable @@ -144,18 +138,13 @@ public void Dispose() _pending.Dispose(); } - /// - /// Emits the buffered value when it is still current, meaning no newer arrived after - /// this emission was scheduled. - /// + /// Emits the buffered value when it is still current, meaning no newer arrived after this emission was scheduled. /// The emission id this callback was scheduled for. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void Emit(long id) + internal void Emit(long id) { T value; lock (_gate) { - // Race-only: reachable when this scheduled callback overlaps Dispose or a terminal notification. if (_done || id != _emissionId || !_hasValue) { return; diff --git a/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs b/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs index 14755c3c..3f752c7e 100644 --- a/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs @@ -46,10 +46,7 @@ public IDisposable Subscribe(IObserver observer) }); } - /// - /// Invokes the function against the resource, signals the produced value - /// followed by completion, then disposes the resource. - /// + /// Invokes the function against the resource, signals the produced value followed by completion, then disposes the resource. /// The downstream observer. /// The resource to use and dispose. /// The function invoked against the resource. diff --git a/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs b/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs index 214ce2d9..c22f6a14 100644 --- a/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs @@ -36,10 +36,7 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// - /// Sink that orchestrates the iteration loop, scheduling the next iteration - /// after each emission and terminating when the predicate becomes false. - /// + /// Sink that orchestrates the iteration loop, scheduling the next iteration after each emission and terminating when the predicate becomes false. /// The downstream observer. /// The loop predicate. /// The action invoked per iteration. diff --git a/src/Primitives.Extensions.Shared/ReactiveExtensions.Retry.cs b/src/Primitives.Extensions.Shared/ReactiveExtensions.Retry.cs index 3d11e9c5..1c06d613 100644 --- a/src/Primitives.Extensions.Shared/ReactiveExtensions.Retry.cs +++ b/src/Primitives.Extensions.Shared/ReactiveExtensions.Retry.cs @@ -134,10 +134,7 @@ public IObservable OnErrorRetry(Action onError, int r onError(tex); })); - /// - /// When caught exception, do onError action and repeat observable sequence after delay - /// time(work on delayScheduler) during within retryCount. - /// + /// When caught exception, do onError action and repeat observable sequence after delay time(work on delayScheduler) during within retryCount. /// The type of the exception. /// The on error. /// The retry count. diff --git a/src/Primitives.Extensions.Shared/ReactiveExtensions.cs b/src/Primitives.Extensions.Shared/ReactiveExtensions.cs index 33feaa0b..208caa36 100644 --- a/src/Primitives.Extensions.Shared/ReactiveExtensions.cs +++ b/src/Primitives.Extensions.Shared/ReactiveExtensions.cs @@ -712,11 +712,7 @@ public IObservable WhereSelect(Func predicate, Func CatchReturn(T fallback) => new CatchReturnObservable(source, fallback); - /// - /// Projects every source element to a stored constant, avoiding the closure - /// allocation of .Select(_ => value). Common in fire-then-return-value - /// chains. - /// + /// Projects every source element to a stored constant, avoiding the closure allocation of .Select(_ => value). Common in fire-then-return-value chains. /// The result element type. /// The constant value emitted for each source element. /// An observable that emits for each source element. @@ -736,10 +732,7 @@ public IObservable SelectConstant(TResult constant) => public IObservable TrySelect(Func selector) => new TrySelectObservable(source, selector); - /// - /// Chains two one-shot SelectMany projections into a single operator. - /// Replaces .SelectMany(a).SelectMany(b) (2 operator allocations) with 1. - /// + /// Chains two one-shot SelectMany projections into a single operator. Replaces .SelectMany(a).SelectMany(b) (2 operator allocations) with 1. /// The intermediate element type. /// The final result type. /// First projection: source → intermediate observable. diff --git a/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs b/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs index 23490893..6fa5d1fc 100644 --- a/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs +++ b/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs @@ -23,6 +23,9 @@ public abstract class CoalescingDispatchScheduler : LocalScheduler /// Cached drain callback (this scheduler's ) marshalled by . private readonly Action _drain; + /// Schedules delays before work returns to the dispatcher. + private readonly IScheduler _delayScheduler; + /// Approximate number of ready items; snapshots a drain batch. private int _readyCount; @@ -30,7 +33,18 @@ public abstract class CoalescingDispatchScheduler : LocalScheduler private int _drainPosted; /// Initializes a new instance of the class. - protected CoalescingDispatchScheduler() => _drain = RunDrain; + /// Scheduler used for relative delays. + internal CoalescingDispatchScheduler(IScheduler delayScheduler) + { + _drain = RunDrain; + _delayScheduler = delayScheduler; + } + + /// Initializes a new instance of the class. + protected CoalescingDispatchScheduler() + : this(DefaultScheduler.Instance) + { + } /// Schedules an action to be executed as soon as possible on the dispatcher. /// The type of the state passed to the action. @@ -76,16 +90,12 @@ public override IDisposable Schedule( /// when the dispatcher accepted the work. protected abstract bool Post(Action drain); - /// - /// Runs on the dispatcher after . The default marshals the wait - /// onto the shared timer, then re-queues the work onto the dispatcher when it is due. Platforms with a native - /// UI-thread timer override this to run the work directly on the dispatcher thread. - /// + /// Schedules delayed work for dispatcher delivery; platforms may override with a native UI timer. /// Callback to invoke on the dispatcher thread when due. /// Relative time after which to invoke . /// The disposable used to cancel the delayed dispatch. protected virtual IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) => - DefaultScheduler.Instance.Schedule( + _delayScheduler.Schedule( (Owner: this, work), dueTime, static (_, state) => state.Owner.Schedule( diff --git a/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs b/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs index be858aa8..0704be55 100644 --- a/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs +++ b/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs @@ -8,12 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; -/// -/// Shared run/cancel core for scheduled, cancellable work items carrying closure-free state and the scheduler passed -/// back to the action. It owns the atomic start-versus-cancel handshake so every dispatcher and event-loop scheduler -/// implements it exactly once; derived types add only the cancellation resources specific to how the work was queued -/// (for example a one-shot timer). -/// +/// Coordinates work execution and cancellation; derived items own scheduling resources. /// The scheduled state type. internal class DispatchWorkItemBase { @@ -36,11 +31,6 @@ internal class DispatchWorkItemBase /// The scheduler passed back to the scheduled action. /// Scheduled state. /// Scheduled action. - /// - /// Written out rather than made a primary constructor so it can stay protected: a primary - /// constructor on a concrete class is public, which would let anything construct the base directly - /// instead of going through a derived work item. - /// protected DispatchWorkItemBase( IScheduler scheduler, TState state, diff --git a/src/Primitives.Shared/Advanced/AsyncDeferSignal{T}.cs b/src/Primitives.Shared/Advanced/AsyncDeferSignal{T}.cs index b2242045..f85abb3d 100644 --- a/src/Primitives.Shared/Advanced/AsyncDeferSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/AsyncDeferSignal{T}.cs @@ -48,7 +48,7 @@ public IDisposable Subscribe(IObserver observer) /// The downstream observer. /// The subscription slot. /// A task that completes when the source subscription has been assigned. - private static async Task RunAsyncFactory( + internal static async Task RunAsyncFactory( Func>> observableFactory, CreateWitness observer, AsyncSubscriptionLifetime subscription) diff --git a/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs b/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs index a2a3e03c..cef1b153 100644 --- a/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs @@ -92,13 +92,8 @@ internal void Release(IDisposable subscription) connection?.Dispose(); } - /// Connects the source outside and publishes or drops the connection. + /// Connects outside the subscription gate and releases the connection if every subscriber has disposed. /// The inner source subscription owned by the connecting observer. - /// - /// Connecting runs outside the lock so a synchronous source cannot drive user callbacks while the gate is - /// held, which lets a re-entrant or concurrent drain the subscriber count before the - /// connection is published. - /// private void ConnectOutsideGate(IDisposable subscription) { var connection = ConnectOrUnwind(subscription); diff --git a/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs b/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs index 0fc3c76d..1bf7ed94 100644 --- a/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/CreateSafeSignal{T}.cs @@ -10,8 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Creates a signal from a subscribe delegate whose sink releases the subscription when a downstream -/// OnNext throws. +/// Creates a signal from a subscribe delegate whose sink releases the subscription when a downstream OnNext throws. /// The value type. internal sealed class CreateSafeSignal : IRequireCurrentThread { diff --git a/src/Primitives.Shared/Advanced/EventHandlerScope.cs b/src/Primitives.Shared/Advanced/EventHandlerScope.cs index 843db703..689fbb05 100644 --- a/src/Primitives.Shared/Advanced/EventHandlerScope.cs +++ b/src/Primitives.Shared/Advanced/EventHandlerScope.cs @@ -38,17 +38,13 @@ public static IDisposable Attach( return AttachScheduled(handler, addHandler, removeHandler, sequencer); } - /// Attaches and later detaches the handler on the sequencer instead of the calling thread. + /// Cancels any pending attachment before scheduling detachment. /// The delegate type used by the event. /// The handler this subscription owns. /// The action that attaches the handler. /// The action that detaches the handler. /// The sequencer that attaches and detaches the handler. /// The disposable that detaches the handler. - /// - /// Disposing cancels a still-pending attach before requesting the detach, so a subscription torn down - /// before the sequencer ran the attach cannot leave the handler on the event. - /// private static IDisposable AttachScheduled( TEventHandler handler, Action addHandler, diff --git a/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs b/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs index f96303f0..d42b7eee 100644 --- a/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs +++ b/src/Primitives.Shared/Advanced/ExpireCoordinator{T}.cs @@ -48,11 +48,7 @@ public sealed class ExpireCoordinator : IObserver, IDisposable /// Monotonic version that suppresses timeouts superseded by a newer value. private long _epoch; - /// - /// The instant on the sequencer's clock at which the current inactivity window closes, read and written under - /// . It starts at so an unpublished window never - /// expires a value. - /// + /// Deadline guarded by the gate; MaxValue prevents expiration until the first window is armed. private DateTimeOffset _deadline = DateTimeOffset.MaxValue; /// Initializes a new instance of the class. @@ -137,11 +133,7 @@ public void OnError(Exception error) } /// - /// - /// A value is on time only when it arrives before the inactivity window closes on the sequencer's clock, not - /// merely before the armed timer has run. A value that arrives past its deadline — which a saturated thread-pool - /// sequencer can allow — terminates the sequence with instead of being forwarded. - /// + /// Values arriving at or after the clock deadline fail with TimeoutException, even if the timer callback has not run. public void OnNext(T value) { long epoch; @@ -199,11 +191,8 @@ public ExpireCoordinator Run() return this; } - /// Schedules a fresh inactivity timer for the given epoch and discards the in-flight one. + /// Schedules outside the gate and publishes the timer only if its window remains current. /// The version this timer must match to fire. - /// Scheduling happens outside the gate so a synchronous sequencer cannot re-enter . - /// The publish is re-checked under the gate, so neither a terminal notification nor a newer value's window can be - /// overwritten by a superseded arm. private void ArmTimer(long epoch) { var deadline = Deadline(); @@ -228,12 +217,8 @@ private void ArmTimer(long epoch) previous?.Dispose(); } - /// Computes the closing instant of an inactivity window opened at the current time. + /// Captures a deadline for positive delays; zero delays remain queued immediate work. /// The deadline on the sequencer's clock, saturated instead of overflowing. - /// - /// A due time that normalizes to zero is queued as immediate work, so it opens no clock window and a synchronous - /// value arriving before the queue drains wins; only a positive due time yields a real deadline. - /// private DateTimeOffset Deadline() { var dueTime = Sequencer.Normalize(_dueTime); diff --git a/src/Primitives.Shared/Advanced/ForkJoinWitness{TLeft,TRight,TResult}.cs b/src/Primitives.Shared/Advanced/ForkJoinWitness{TLeft,TRight,TResult}.cs index a65028ea..b65c9af7 100644 --- a/src/Primitives.Shared/Advanced/ForkJoinWitness{TLeft,TRight,TResult}.cs +++ b/src/Primitives.Shared/Advanced/ForkJoinWitness{TLeft,TRight,TResult}.cs @@ -131,8 +131,7 @@ private void OnError(Exception error) } } - /// Emits the result and completes once both sources are done. - /// Must be called while holding so the terminal notification stays serialized. + /// Sends the terminal notification while the caller holds the gate. private void TryFinish() { if (!IsLeftDone || !IsRightDone) diff --git a/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs b/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs index 0f283e61..fc2a6b3c 100644 --- a/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/GuardedWitness{T}.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// -/// Forwarding observer that releases its upstream cancel resource when the sequence terminates or a downstream -/// OnNext throws, rethrowing the latter after release. -/// +/// Forwarding observer that releases its upstream cancel resource when the sequence terminates or a downstream OnNext throws, rethrowing the latter after release. /// The value type. [System.Diagnostics.DebuggerDisplay("GuardedWitness: Disposed = {_disposed}, Observer = {_observer}")] public sealed class GuardedWitness : IObserver, IDisposable diff --git a/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs b/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs index 4ebd7d17..a8b984f4 100644 --- a/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs +++ b/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs @@ -48,10 +48,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // The first value settles this operator, so the sink must own the subscription before any value arrives. A - // current-thread source drains the trampoline inside whichever call enters it first, so letting the source's - // own Subscribe enter it would deliver values before the sink holds the subscription, and an endless source - // would never stop. Entering the trampoline here leaves the source only able to queue its first tick. + // Acquire the subscription before the first value can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs b/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs index 2b8b016b..d2688174 100644 --- a/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs +++ b/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs @@ -84,9 +84,7 @@ public void OnSource(IObservable? source) Active++; } - // A source is free to signal completion more than once. Latch per inner so a repeat cannot decrement - // the active count on behalf of a running sibling, which would complete the merge early and drop - // everything that sibling had left to produce. + // Latch completion per source so duplicate terminal notifications cannot decrement a sibling's count. var completed = 0; Subscriptions.Add(source.Subscribe( OnInnerNext, diff --git a/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs b/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs index 3ce907c8..786fd5b9 100644 --- a/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// -/// Cold signal that mirrors the source and, when it produces any error, subscribes the fallback sequence in its -/// place. The error is not forwarded downstream. -/// +/// Cold signal that mirrors the source and, when it produces any error, subscribes the fallback sequence in its place. The error is not forwarded downstream. /// The value type. /// The source observable. /// The fallback observable subscribed to after the source errors. diff --git a/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs b/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs index 5e70b6d2..7d7f808d 100644 --- a/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs +++ b/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs @@ -8,15 +8,10 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// -/// Subscription-time scheduling shared by advanced signals: a signal that observes the current-thread -/// sequencer has to queue behind it while it is draining work, so emissions arrive in trampoline order. -/// +/// Preserves trampoline order when subscribing during a current-thread drain. internal static class SubscriptionScheduling { - /// - /// Subscribes inline when the current-thread sequencer is idle, otherwise queues the subscription on it. - /// + /// Subscribes inline when the current-thread sequencer is idle, otherwise queues the subscription on it. /// The type of the state passed to the subscribe callback. /// The state carried to . /// Performs the subscription and returns it. diff --git a/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs b/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs index 27a9b1aa..5c799da8 100644 --- a/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs +++ b/src/Primitives.Shared/Advanced/SwitchMapSignal{TSource,TResult}.cs @@ -13,10 +13,6 @@ namespace ReactiveUI.Primitives.Advanced; /// Projects each source value to an inner observable and mirrors only the latest one. /// The source element type. /// The element type of the projected inner observables. -/// -/// The projection is fused into the switch, so it costs one object and one observer hop instead of the two of -/// each, plus an intermediate sequence of observables, that a separate projection and switch cost. -/// [System.Diagnostics.DebuggerDisplay("SwitchMapSignal: Source = {_source}, SkipNullSources = {_skipNullSources}")] public sealed class SwitchMapSignal : IObservable { @@ -41,8 +37,8 @@ public SwitchMapSignal(IObservable source, FuncThe source whose values are projected to inner observables. /// Projects a source value to the inner observable to switch to. /// - /// Whether a null source value leaves the active inner subscription in place rather than switching. A caller - /// that projects null onto its own inner observable switches on it instead, which detaches the previous one. + /// Whether null source values retain the active subscription + /// without invoking the selector. /// internal SwitchMapSignal( IObservable source, diff --git a/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs b/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs index d4c5b23f..3c054045 100644 --- a/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs @@ -80,10 +80,7 @@ private void OnSource(IObservable source) error => OnError(current, error), () => OnCompleted(current)); - // Subscribing can push a value downstream synchronously, and that handler is free to feed the outer - // source again, re-entering OnSource and installing a newer generation before this call returns. Only - // the current generation may occupy the slot: installing a superseded subscription would dispose the - // newer one and leave a sequence whose notifications are all filtered out by version. + // Reentrant subscription can install a newer generation; only the current generation may occupy the slot. var superseded = false; lock (_gate) { diff --git a/src/Primitives.Shared/Advanced/TaskInstanceSignal{T}.cs b/src/Primitives.Shared/Advanced/TaskInstanceSignal{T}.cs index c3f40877..2d7c3db5 100644 --- a/src/Primitives.Shared/Advanced/TaskInstanceSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/TaskInstanceSignal{T}.cs @@ -40,7 +40,7 @@ public IDisposable Subscribe(IObserver observer) /// The downstream observer. /// The subscription lifetime. /// A task representing the asynchronous observation. - private static async Task ObserveTaskAsync( + internal static async Task ObserveTaskAsync( Task task, IObserver observer, TaskInstanceSubscription subscription) diff --git a/src/Primitives.Shared/Advanced/TimerSlot.cs b/src/Primitives.Shared/Advanced/TimerSlot.cs index c0a1a04e..890f7659 100644 --- a/src/Primitives.Shared/Advanced/TimerSlot.cs +++ b/src/Primitives.Shared/Advanced/TimerSlot.cs @@ -17,11 +17,7 @@ public static class TimerSlot /// The delay before the callback runs. /// The timer callback. /// An argument is . - /// - /// A sequencer may run before its own Schedule returns, and that callback may - /// arm its successor. Reserving first sends the late handle into a superseded - /// reservation, so the fired timer is disposed instead of the successor being cancelled. - /// + /// A callback that runs during scheduling can rearm the slot without its successor being canceled. public static void Arm(SingleReplaceableDisposable slot, ISequencer sequencer, TimeSpan delay, Action tick) { ArgumentExceptionHelper.ThrowIfNull(slot); diff --git a/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs b/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs index b7a8c76f..1b526797 100644 --- a/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs +++ b/src/Primitives.Shared/Concurrency/SequencerWorkItemDisposal.cs @@ -11,13 +11,9 @@ namespace ReactiveUI.Primitives.Concurrency; /// Disposal helpers shared by sequencer work items. internal static class SequencerWorkItemDisposal { - /// Publishes the action's disposable into the shared slot, disposing it when disposal won the race. + /// Publishes the disposable or releases it if disposal owns the slot. /// The disposable slot shared with the work item's disposal. /// The disposable returned by the scheduled action. - /// - /// Disposal swaps a non-null sentinel into the slot, so a non-null exchange result means disposal owns the - /// slot and this caller must release the disposable it produced. - /// internal static void Publish(ref IDisposable? slot, IDisposable disposable) { if (Interlocked.CompareExchange(ref slot, disposable, null) is null) diff --git a/src/Primitives.Shared/Core/SparkCompositeFormats.cs b/src/Primitives.Shared/Core/SparkCompositeFormats.cs index bb1b5b6a..99f2f6df 100644 --- a/src/Primitives.Shared/Core/SparkCompositeFormats.cs +++ b/src/Primitives.Shared/Core/SparkCompositeFormats.cs @@ -10,11 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Core; namespace ReactiveUI.Primitives.Core; #endif -/// Holds the parsed composite formats renders with. -/// -/// A static field in is a distinct field per closed generic, so the formats live on a -/// non-generic type to be parsed once for every T. -/// +/// Shares parsed spark formats across all closed generic types. internal static class SparkCompositeFormats { /// The format an spark renders with. diff --git a/src/Primitives.Shared/Core/Spark{T}.cs b/src/Primitives.Shared/Core/Spark{T}.cs index 366358bc..72fc4bf5 100644 --- a/src/Primitives.Shared/Core/Spark{T}.cs +++ b/src/Primitives.Shared/Core/Spark{T}.cs @@ -12,10 +12,7 @@ namespace ReactiveUI.Primitives.Reactive.Core; namespace ReactiveUI.Primitives.Core; #endif -/// -/// Represents a spark to an observer. Being a value type, materializing a sequence allocates no -/// per-notification heap object. -/// +/// Represents a spark to an observer. Being a value type, materializing a sequence allocates no per-notification heap object. /// The type of the elements received by the observer. [Serializable] [DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -56,10 +53,7 @@ private Spark(SparkKind kind, T value, Exception? exception) [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// - /// Determines whether both sparks carry the same observer message payload: the same and, - /// for OnNext and OnError, the same value or exception. - /// + /// Determines whether both sparks carry the same observer message payload: the same and, for OnNext and OnError, the same value or exception. /// The spark to compare with this one. /// true if both sparks carry the same payload; otherwise, false. public bool Equals(Spark other) => Kind == other.Kind && Kind switch diff --git a/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs b/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs index 61df4a34..f9a617d8 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs @@ -18,10 +18,7 @@ public static partial class LinqExtensions /// The source observable. extension(IObservable source) { - /// - /// Projects each source value to an inner observable and mirrors only the latest one — a single fused - /// sink in place of Select(selector).Switch(). - /// + /// Projects each source value to an inner observable and mirrors only the latest one — a single fused sink in place of Select(selector).Switch(). /// The element type of the projected inner observables. /// Projects each source value to an inner observable. /// An observable that mirrors the latest projected inner observable. diff --git a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs index af954501..cadbaa0b 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs @@ -405,10 +405,7 @@ private sealed class CombineLatestCoordinator : IDisposable /// The active source subscriptions. private readonly MultipleDisposable _subscriptions = []; - /// - /// One flag per source twice over: the first half records whether a source has produced a value, the - /// second whether it has completed. - /// + /// One flag per source twice over: the first half records whether a source has produced a value, the second whether it has completed. private bool[] _flags = []; /// The projection over this subscription's slots. diff --git a/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs b/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs index c1a27d67..c0bce856 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs @@ -13,9 +13,7 @@ namespace ReactiveUI.Primitives; /// Coordinator helpers for multi-source signal operators. public static partial class LinqExtensions { - /// - /// Range-specialized WithLatest (Latch): emits each left range value paired with the right range's final value. - /// + /// Range-specialized WithLatest (Latch): emits each left range value paired with the right range's final value. /// The result value type. /// The left source range. /// The right source range. @@ -481,10 +479,7 @@ private void OnRightCompleted() Drain(); } - /// - /// Emits every pair the queues can form. The gate is held across the projection and the downstream - /// callbacks, so the left and right threads cannot interleave emissions. - /// + /// Projects and emits queued pairs under the gate, preserving emission order. private void Drain() { lock (_gate) diff --git a/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs index 05594380..ea076748 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs @@ -16,113 +16,13 @@ namespace ReactiveUI.Primitives; /// public static partial class LinqExtensions { - /// Dedicated signal for Calm (quiet-period debounce). - /// The value type. - /// The source observable. - /// The quiet period. - /// The sequencer used to schedule quiet-period timers. - private sealed class CalmSignal(IObservable source, TimeSpan dueTime, ISequencer scheduler) : IRequireCurrentThread - { - /// The source observable. - private readonly IObservable _source = source; - - /// The quiet period. - private readonly TimeSpan _dueTime = dueTime; - - /// The sequencer used to schedule quiet-period timers. - private readonly ISequencer _scheduler = scheduler; - - /// - public bool IsRequiredSubscribeOnCurrentThread() => _scheduler == Sequencer.CurrentThread; - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - CalmCoordinator coordinator = new(_source, _dueTime, _scheduler); - if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) - { - return coordinator.Run(observer); - } - - SingleDisposable subscription = new(); - _ = Sequencer.CurrentThread.Schedule( - (subscription, coordinator, observer), - static (_, s) => - { - s.subscription.Create(s.coordinator.Run(s.observer)); - return EmptyDisposable.Instance; - }); - return subscription; - } - } - - /// Dedicated signal for Shift (delay each notification on a sequencer). - /// The value type. - private sealed class ShiftSignal : IRequireCurrentThread - { - /// The source observable. - private readonly IObservable _source; - - /// The delay applied to each notification. - private readonly TimeSpan _dueTime; - - /// The sequencer used to schedule delayed notifications. - private readonly ISequencer _scheduler; - - /// Initializes a new instance of the class. - /// The source observable. - /// The delay applied to each notification. - /// The sequencer used to schedule delayed notifications. - internal ShiftSignal(IObservable source, TimeSpan dueTime, ISequencer scheduler) - { - _source = source; - _dueTime = Sequencer.Normalize(dueTime); - _scheduler = scheduler; - } - - /// - public bool IsRequiredSubscribeOnCurrentThread() => _scheduler == Sequencer.CurrentThread; - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) - { - return RunCore(observer); - } - - SingleDisposable subscription = new(); - _ = Sequencer.CurrentThread.Schedule( - (Self: this, subscription, observer), - static (_, s) => - { - s.subscription.Create(s.Self.RunCore(s.observer)); - return EmptyDisposable.Instance; - }); - return subscription; - } - - /// Subscribes to the source and schedules each notification by the delay. - /// The downstream observer. - /// The disposable that cancels the source subscription and pending timers. - private ShiftCoordinator RunCore(IObserver observer) - { - ShiftCoordinator coordinator = new(_source, _dueTime, _scheduler, observer); - return coordinator.Run(); - } - } - /// Coordinates delayed notification delivery with a single serialized timer. /// The source value type. /// The source observable. /// The normalized delay applied to each notification. /// The sequencer used to schedule delayed notifications. /// The downstream observer. - private sealed class ShiftCoordinator(IObservable source, TimeSpan dueTime, ISequencer sequencer, IObserver observer) : IDisposable + internal sealed class ShiftCoordinator(IObservable source, TimeSpan dueTime, ISequencer sequencer, IObserver observer) : IDisposable { /// The source observable. private readonly IObservable _source = source; @@ -179,11 +79,22 @@ private enum NotificationKind /// public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + if (!TryBeginDispose()) { return; } + ReleaseSubscriptions(); + } + + /// Claims disposal before waiting for an in-flight notification. + /// Whether this call owns resource cleanup. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool TryBeginDispose() => Interlocked.Exchange(ref _disposed, 1) == 0; + + /// Releases resources after the active notification leaves the gate. + internal void ReleaseSubscriptions() + { lock (_gate) { _timer.Dispose(); @@ -393,6 +304,106 @@ public static DelayedNotification Completed(DateTimeOffset dueAt) => } } + /// Dedicated signal for Calm (quiet-period debounce). + /// The value type. + /// The source observable. + /// The quiet period. + /// The sequencer used to schedule quiet-period timers. + private sealed class CalmSignal(IObservable source, TimeSpan dueTime, ISequencer scheduler) : IRequireCurrentThread + { + /// The source observable. + private readonly IObservable _source = source; + + /// The quiet period. + private readonly TimeSpan _dueTime = dueTime; + + /// The sequencer used to schedule quiet-period timers. + private readonly ISequencer _scheduler = scheduler; + + /// + public bool IsRequiredSubscribeOnCurrentThread() => _scheduler == Sequencer.CurrentThread; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + CalmCoordinator coordinator = new(_source, _dueTime, _scheduler); + if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) + { + return coordinator.Run(observer); + } + + SingleDisposable subscription = new(); + _ = Sequencer.CurrentThread.Schedule( + (subscription, coordinator, observer), + static (_, s) => + { + s.subscription.Create(s.coordinator.Run(s.observer)); + return EmptyDisposable.Instance; + }); + return subscription; + } + } + + /// Dedicated signal for Shift (delay each notification on a sequencer). + /// The value type. + private sealed class ShiftSignal : IRequireCurrentThread + { + /// The source observable. + private readonly IObservable _source; + + /// The delay applied to each notification. + private readonly TimeSpan _dueTime; + + /// The sequencer used to schedule delayed notifications. + private readonly ISequencer _scheduler; + + /// Initializes a new instance of the class. + /// The source observable. + /// The delay applied to each notification. + /// The sequencer used to schedule delayed notifications. + internal ShiftSignal(IObservable source, TimeSpan dueTime, ISequencer scheduler) + { + _source = source; + _dueTime = Sequencer.Normalize(dueTime); + _scheduler = scheduler; + } + + /// + public bool IsRequiredSubscribeOnCurrentThread() => _scheduler == Sequencer.CurrentThread; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) + { + return RunCore(observer); + } + + SingleDisposable subscription = new(); + _ = Sequencer.CurrentThread.Schedule( + (Self: this, subscription, observer), + static (_, s) => + { + s.subscription.Create(s.Self.RunCore(s.observer)); + return EmptyDisposable.Instance; + }); + return subscription; + } + + /// Subscribes to the source and schedules each notification by the delay. + /// The downstream observer. + /// The disposable that cancels the source subscription and pending timers. + private ShiftCoordinator RunCore(IObserver observer) + { + ShiftCoordinator coordinator = new(_source, _dueTime, _scheduler, observer); + return coordinator.Run(); + } + } + /// Dedicated signal for absolute Shift overloads. /// The value type. /// The source observable. diff --git a/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs index 8f5b7a35..bcd24388 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs @@ -36,9 +36,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // A current-thread source drains the trampoline inside whichever call enters it first. When that call - // is the source's own Subscribe, the sink does not hold the upstream subscription yet, so an endless - // source never learns the count was reached. Entering the trampoline here leaves Subscribe queueing only. + // Acquire the subscription before the trampoline delivers values that can terminate it. if (!CurrentThreadRequirement.IsRequired(_source) || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); @@ -84,9 +82,7 @@ public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - // Either arm can be a current-thread source, and whichever is subscribed first drains the trampoline - // inside that call, before the coordinator holds the subscription it needs in order to stop. Entering - // the trampoline here leaves both arms queueing only, so the coordinator owns both subscriptions first. + // Acquire both subscriptions before either trampoline starts delivering values. if ((!CurrentThreadRequirement.IsRequired(_source) && !CurrentThreadRequirement.IsRequired(_other)) || !CurrentThreadSequencer.IsScheduleRequired) { @@ -458,9 +454,7 @@ public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - // A current-thread source drains its trampoline inside its own Subscribe, so on an endless source the sink - // never receives the upstream subscription it would have to dispose. Entering the trampoline here leaves - // the source queueing only, so a failing predicate can stop it. + // Acquire the subscription before a predicate can stop the source. if (!CurrentThreadRequirement.IsRequired(_source) || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/SignalOperatorMixins.Switch.cs b/src/Primitives.Shared/SignalOperatorMixins.Switch.cs index 6457b889..d098d34f 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.Switch.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.Switch.cs @@ -11,29 +11,9 @@ namespace ReactiveUI.Primitives; /// The Switch operator: subscribes to the most recent inner sequence and drops the previous one. public static partial class LinqExtensions { - /// Dedicated signal for SwitchTo that hands each subscription to a coordinator. - /// The value type. - private sealed class SwitchSignal : IObservable - { - /// The outer sequence of inner sources. - private readonly IObservable> _sources; - - /// Initializes a new instance of the class. - /// The outer sequence of inner sources. - internal SwitchSignal(IObservable> sources) => _sources = sources; - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - return new SwitchCoordinator(observer).Run(_sources); - } - } - /// Coordinates a switch operation. /// The source value type. - private sealed class SwitchCoordinator : IDisposable + internal sealed class SwitchCoordinator : IDisposable { /// The synchronization gate. private readonly Lock _gate = new(); @@ -63,6 +43,9 @@ private sealed class SwitchCoordinator : IDisposable /// The downstream observer. internal SwitchCoordinator(IObserver observer) => _observer = observer; + /// Gets the gate serializing switches and downstream notifications. + internal Lock Gate => _gate; + /// Releases the active subscriptions. public void Dispose() { @@ -197,4 +180,24 @@ private void TryComplete() _observer.OnCompleted(); } } + + /// Dedicated signal for SwitchTo that hands each subscription to a coordinator. + /// The value type. + private sealed class SwitchSignal : IObservable + { + /// The outer sequence of inner sources. + private readonly IObservable> _sources; + + /// Initializes a new instance of the class. + /// The outer sequence of inner sources. + internal SwitchSignal(IObservable> sources) => _sources = sources; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + return new SwitchCoordinator(observer).Run(_sources); + } + } } diff --git a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs index 7d486880..77a9498a 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.MultiSource.cs @@ -18,10 +18,7 @@ public static partial class LinqExtensions /// Source observable 1 whose latest value is combined. extension(IObservable source) { - /// - /// Combines the latest values from 3 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 3 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The projected element type. @@ -47,10 +44,7 @@ public IObservable SyncLatest( selector); } - /// - /// Combines the latest values from 4 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 4 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -81,10 +75,7 @@ public IObservable SyncLatest( selector); } - /// - /// Combines the latest values from 5 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 5 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -120,10 +111,7 @@ public IObservable SyncLatest( selector); } - /// - /// Combines the latest values from 6 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 6 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -164,10 +152,7 @@ public IObservable SyncLatest( selector); } - /// - /// Combines the latest values from 7 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 7 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -213,10 +198,7 @@ public IObservable SyncLatest( selector); } - /// - /// Combines the latest values from 8 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 8 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -271,10 +253,7 @@ public IObservable SyncLatest( selector); } - /// - /// Combines the latest values from 9 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 9 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs index c5ff8289..8b85dfce 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.SyncLatest.WideArity.cs @@ -18,10 +18,7 @@ public static partial class LinqExtensions /// Source observable 1 whose latest value is combined. extension(IObservable source) { - /// - /// Combines the latest values from 10 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 10 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -86,10 +83,7 @@ public IObservable SyncLatest - /// Combines the latest values from 11 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 11 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -159,10 +153,7 @@ public IObservable SyncLatest - /// Combines the latest values from 12 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 12 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -237,10 +228,7 @@ public IObservable SyncLatest - /// Combines the latest values from 13 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 13 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -320,10 +308,7 @@ public IObservable SyncLatest - /// Combines the latest values from 14 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 14 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -408,10 +393,7 @@ public IObservable SyncLatest - /// Combines the latest values from 15 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 15 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -501,10 +483,7 @@ public IObservable SyncLatest - /// Combines the latest values from 16 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 16 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/Primitives.Shared/SignalOperatorMixins.cs b/src/Primitives.Shared/SignalOperatorMixins.cs index eafa2030..fb5e66b2 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.cs @@ -136,10 +136,7 @@ public IObservable MapIndexed(Func selector) return new MapIndexedSignal(source, selector); } - /// - /// Projects each element of an observable sequence into a new form by incorporating state that is passed to the - /// selector function. - /// + /// Projects each element of an observable sequence into a new form by incorporating state that is passed to the selector function. /// The type of the state used in the selector function. /// The type of the elements in the result sequence. /// The state to pass to the selector function. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs index a7b60465..0b0e84f3 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs @@ -338,9 +338,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // The first value settles this operator, so it must be able to dispose the source the moment one arrives. - // A current-thread source drains its trampoline inside its own Subscribe, which would not return until the - // source ended — never, for an endless one — leaving the sink without the subscription it needs to stop it. + // Acquire the subscription before the first value can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); @@ -395,8 +393,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // The first matching value settles this operator, so it must own the source subscription before the source - // starts producing. See AnySignal for why a current-thread source livelocks without this bootstrap. + // Acquire the subscription before a match can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs index 0e786046..e386144f 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs @@ -38,9 +38,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // The first value the predicate rejects settles this operator, so it must own the source subscription - // before the source starts producing. A current-thread source drains its trampoline inside its own - // Subscribe, so on an endless source the sink would never be handed the subscription it needs to stop it. + // Acquire the subscription before a rejected value can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); @@ -129,8 +127,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // The value being sought settles this operator the moment it arrives, so it must own the source - // subscription before the source starts producing. See AllPredicateSignal for the livelock without this. + // Acquire the subscription before a match can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs index 39229e3e..26f721cf 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs @@ -228,9 +228,7 @@ private void OnOuterCompleted() /// The inner value. private void OnInnerNext(TResult value) { - // Hot path: only one inner is active at a time (sequential concat semantics), so this forward is - // serialized without the gate. A volatile read of the disposed flag avoids a monitor acquire on - // every value; the lock releases elsewhere publish the write. + // One inner source runs at a time; the volatile disposal check needs no delivery gate. if (Volatile.Read(ref _disposed)) { return; diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs index 085202b6..026c59f7 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs @@ -543,9 +543,7 @@ private void OnCompleted() _done = true; - // Completion cuts the quiet window short rather than cancelling it, so a value the window was - // holding is delivered first. A value the timer delivered cleared _hasLatest under this same - // gate, so it cannot be emitted twice. + // Completion flushes the pending value; the timer clears the same flag under this gate. if (_hasLatest) { _hasLatest = false; diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs b/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs index 32bc2f72..4a9d399f 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs @@ -109,9 +109,7 @@ public void Dispose() return; } - // Latch the terminal state as well as the resources: a source that ignores the disposal of its - // subscription can keep pushing, and its completion or error must not reach an unsubscribed observer. - // The gate is reentrant, so a terminal path may reach this while holding it. + // Latch termination to reject notifications from sources that ignore disposal. lock (_gate) { _done = true; diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs index 75860fea..e8633acc 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Tuples.cs @@ -17,7 +17,6 @@ public static partial class LinqExtensions /// System.Reactive-named tuple-returning latest-value combination operators. /// The element type of source 1. /// Source observable 1 whose latest value is combined. - /// Lower overload priority keeps a call that passes an untyped null binding to the selector overload. extension(IObservable source) { /// Combines latest values from 2 observable sources into tuple values. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs index f64774a8..4bb3c7f5 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.WideArity.cs @@ -19,10 +19,7 @@ public static partial class LinqExtensions /// Source observable 1 whose latest value is combined. extension(IObservable source) { - /// - /// Combines the latest values from 10 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 10 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -73,10 +70,7 @@ public IObservable CombineLatest - /// Combines the latest values from 11 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 11 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -131,10 +125,7 @@ public IObservable CombineLatest - /// Combines the latest values from 12 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 12 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -193,10 +184,7 @@ public IObservable CombineLatest - /// Combines the latest values from 13 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 13 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -259,10 +247,7 @@ public IObservable CombineLatest - /// Combines the latest values from 14 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 14 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -329,10 +314,7 @@ public IObservable CombineLatest - /// Combines the latest values from 15 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 15 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -403,10 +385,7 @@ public IObservable CombineLatest - /// Combines the latest values from 16 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 16 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs index 390af0f3..a68e7041 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.cs @@ -18,10 +18,7 @@ public static partial class LinqExtensions /// Source observable 1 whose latest value is combined. extension(IObservable source) { - /// - /// Combines the latest values from 3 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 3 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The projected element type. @@ -47,10 +44,7 @@ public IObservable CombineLatest( selector); } - /// - /// Combines the latest values from 4 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 4 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -81,10 +75,7 @@ public IObservable CombineLatest( selector); } - /// - /// Combines the latest values from 5 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 5 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -120,10 +111,7 @@ public IObservable CombineLatest( selector); } - /// - /// Combines the latest values from 6 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 6 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -164,10 +152,7 @@ public IObservable CombineLatest( selector); } - /// - /// Combines the latest values from 7 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 7 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -213,10 +198,7 @@ public IObservable CombineLatest( selector); } - /// - /// Combines the latest values from 8 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 8 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -271,10 +253,7 @@ public IObservable CombineLatest( selector); } - /// - /// Combines the latest values from 9 observable sources into a single sequence, - /// projecting them through whenever any source emits. - /// + /// Combines the latest values from 9 observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.cs b/src/Primitives.Shared/SignalOperatorParityMixins.cs index 3bacf622..a47bd918 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.cs @@ -796,8 +796,7 @@ public IObservable ToObservable() /// The receiver task is . public Task ToTask() => task ?? throw new ArgumentNullException(nameof(task)); - /// Returns a task that mirrors the supplied task but transitions to the canceled state when - /// is canceled first. + /// Returns a task that mirrors the supplied task but transitions to the canceled state when is canceled first. /// The token used to cancel the returned task. /// The supplied task, or a task that completes with the supplied task's outcome or cancels when is canceled. /// The receiver task is . @@ -837,8 +836,7 @@ private static async Task AwaitWithCancellationAsync(Task task, Cancell } catch (OperationCanceledException) { - // The wait is abandoned while the underlying task can keep running, so observe any later fault - // to keep it from surfacing as an UnobservedTaskException on the finalizer thread. + // Observe faults from work that outlives cancellation of the wait. _ = task.ContinueWith( static abandoned => _ = abandoned.Exception, CancellationToken.None, diff --git a/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs b/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs index bc2c2f7f..b971523a 100644 --- a/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs +++ b/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs @@ -192,11 +192,7 @@ private bool Enqueue(T value) } } - /// Forwards queued values downstream while capacity is available. - /// - /// Only one thread delivers downstream at a time: a caller that finds a drain in progress leaves its work to the - /// owner, and the owner re-checks for queued work under the gate before relinquishing ownership, so no wakeup is lost. - /// + /// Drains queued notifications with one delivery owner, checking for pending work before releasing ownership. private void YieldUntilEmptyOrBlocked() { if (!TryBeginDrain()) @@ -252,8 +248,7 @@ private bool TryTakeNextDrainItem(out DrainItem item) return true; } - // No work remains; release ownership atomically with the empty check so a - // producer that queues work after this point will be able to begin a fresh drain. + // Release ownership under the queue gate so subsequent producers can start a drain. _isDraining = false; return false; } diff --git a/src/Primitives.Shared/Signals/ReplaySignal{T}.cs b/src/Primitives.Shared/Signals/ReplaySignal{T}.cs index 06b85cd6..c051205e 100644 --- a/src/Primitives.Shared/Signals/ReplaySignal{T}.cs +++ b/src/Primitives.Shared/Signals/ReplaySignal{T}.cs @@ -137,6 +137,9 @@ public ReplaySignal(TimeSpan window) /// Gets a value indicating whether this instance is disposed. public bool IsDisposed { get; private set; } + /// Gets the gate shared by replay and live delivery. + internal Lock Gate => _observerLock; + /// Gets the debugger display text. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] @@ -214,10 +217,7 @@ public void OnError(Exception error) /// Buffers the value for replay and broadcasts it to the current observers. /// The value to emit. - /// - /// The append and the broadcast share the gate holds while it attaches an observer and - /// replays the buffer, so a value reaches a new observer either through replay or live, never both or out of order. - /// + /// Concurrent subscription receives each value once, through replay or live delivery, in emission order. public void OnNext(T value) { // Read the scheduler clock outside the lock; the window inputs are immutable. diff --git a/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs b/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs index a15b63e8..200334d2 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{Recover}.cs @@ -32,9 +32,7 @@ public IObservable Recover() /// Source sequence to recover or clean up. extension(IObservable source) { - /// - /// Continues an observable sequence that is terminated by an exception of the specified type with the observable sequence produced by the handler. - /// + /// Continues an observable sequence that is terminated by an exception of the specified type with the observable sequence produced by the handler. /// The type of the exception to catch and handle. Needs to derive from . /// Exception handler function, producing another observable sequence. /// diff --git a/src/Primitives.Shared/Signals/Signal{Create}.cs b/src/Primitives.Shared/Signals/Signal{Create}.cs index 61632298..182218f6 100644 --- a/src/Primitives.Shared/Signals/Signal{Create}.cs +++ b/src/Primitives.Shared/Signals/Signal{Create}.cs @@ -76,10 +76,7 @@ public static IObservable Create( return new CreateSignal(subscribe, isRequiredSubscribeOnCurrentThread); } - /// - /// Creates a signal that passes the state to the subscribe function for each observer, so the function can be - /// static instead of capturing a closure. - /// + /// Creates a signal that passes the state to the subscribe function for each observer, so the function can be static instead of capturing a closure. /// The element type. /// The type of the captured state. /// The state passed to on each subscription. @@ -95,10 +92,7 @@ public static IObservable CreateWithState( return new CreateSignal(state, subscribe); } - /// - /// Creates a signal that passes the state to the subscribe function for each observer, so the function can be - /// static instead of capturing a closure. - /// + /// Creates a signal that passes the state to the subscribe function for each observer, so the function can be static instead of capturing a closure. /// The element type. /// The type of the captured state. /// The state passed to on each subscription. @@ -116,10 +110,7 @@ public static IObservable CreateWithState( return new CreateSignal(state, subscribe, isRequiredSubscribeOnCurrentThread); } - /// - /// Creates a signal that runs the subscribe function for each observer and releases the subscription when a - /// downstream OnNext throws, which suits cold signals. - /// + /// Creates a signal that runs the subscribe function for each observer and releases the subscription when a downstream OnNext throws, which suits cold signals. /// The element type. /// Invoked for each observer; returns the disposable that releases the subscription. /// A signal backed by . @@ -131,10 +122,7 @@ public static IObservable CreateSafe(Func, IDisposable> subsc return new CreateSafeSignal(subscribe); } - /// - /// Creates a signal that runs the subscribe function for each observer and releases the subscription when a - /// downstream OnNext throws, which suits cold signals. - /// + /// Creates a signal that runs the subscribe function for each observer and releases the subscription when a downstream OnNext throws, which suits cold signals. /// The element type. /// Invoked for each observer; returns the disposable that releases the subscription. /// Whether subscription must be dispatched through the current-thread sequencer. diff --git a/src/Primitives.Shared/Signals/Signal{FromTask}.cs b/src/Primitives.Shared/Signals/Signal{FromTask}.cs index 201b5645..161c63ec 100644 --- a/src/Primitives.Shared/Signals/Signal{FromTask}.cs +++ b/src/Primitives.Shared/Signals/Signal{FromTask}.cs @@ -74,6 +74,61 @@ public static ITaskSignal FromTask( CancellationTokenSource? cancellationTokenSource) => CreateTaskSignal(actionAsync, scheduler, cancellationTokenSource); + /// Builds a disposer that cancels the source if it wins the terminal transition. + /// The terminal-notification gate shared with the continuation. + /// The cancellation source to cancel on disposal. + /// The disposer. + internal static ActionDisposable CancelOnDispose(TaskStopGate gate, CancellationTokenSource source) => + new(() => + { + if (!gate.TryStop()) + { + return; + } + + Cancel(source); + }); + + /// Delivers task completion only if the subscription has not claimed disposal. + /// The result type. + /// The task raced against cancellation. + /// The observer receiving the notification. + /// The terminal-notification gate shared with the disposer. + /// The token checked for cancellation. + /// A task that completes once the notification is forwarded or suppressed. + internal static async Task ObserveTask( + Task<(TResult Value, bool IsCanceled)> cancellableTask, + IObserver observer, + TaskStopGate gate, + CancellationToken token) + { + try + { + var (result, isCanceled) = await cancellableTask.ConfigureAwait(false); + if (!gate.TryStop()) + { + return; + } + + if (!isCanceled && !token.IsCancellationRequested) + { + observer.OnNext(result); + observer.OnCompleted(); + } + else + { + observer.OnError(new OperationCanceledException()); + } + } + catch (Exception error) + { + if (gate.TryStop()) + { + observer.OnError(error); + } + } + } + /// Builds the task-backed signal, taking a direct-subscription form for the immediate sequencer. /// The result type. /// The function to execute. @@ -128,30 +183,12 @@ private static IDisposable SubscribeTask( return CancelOnDispose(gate, source); } - /// Builds a disposer that cancels the source if it wins the terminal transition. - /// The terminal-notification gate shared with the continuation. - /// The cancellation source to cancel on disposal. - /// The disposer. - private static ActionDisposable CancelOnDispose(TaskStopGate gate, CancellationTokenSource source) => - new(() => - { - if (!gate.TryStop()) - { - return; - } - - Cancel(source); - }); - - /// Emits the terminal notification synchronously when the task has finished. + /// Delivers synchronous completion before the subscription handle is returned. /// The result type. /// The task to inspect. /// The observer receiving the notification. /// The token checked for cancellation. /// when a synchronous terminal notification was produced. - /// - /// Runs before any disposer is handed out, so no dispose race is possible and the emission needs no gate. - /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", @@ -183,50 +220,6 @@ private static bool TryEmitSynchronously( return true; } - /// Observes a pending task and forwards the terminal notification while honoring disposal. - /// The result type. - /// The task raced against cancellation. - /// The observer receiving the notification. - /// The terminal-notification gate shared with the disposer. - /// The token checked for cancellation. - /// A task that completes once the notification is forwarded or suppressed. - /// - /// The notification is gated on , the transition the disposer wins on teardown, - /// so a subscription disposed while this continuation runs observes nothing. - /// - private static async Task ObserveTask( - Task<(TResult Value, bool IsCanceled)> cancellableTask, - IObserver observer, - TaskStopGate gate, - CancellationToken token) - { - try - { - var (result, isCanceled) = await cancellableTask.ConfigureAwait(false); - if (!gate.TryStop()) - { - return; - } - - if (!isCanceled && !token.IsCancellationRequested) - { - observer.OnNext(result); - observer.OnCompleted(); - } - else - { - observer.OnError(new OperationCanceledException()); - } - } - catch (Exception error) - { - if (gate.TryStop()) - { - observer.OnError(error); - } - } - } - /// Cancels the source, tolerating a source another completion path disposed. /// The cancellation source to cancel. private static void Cancel(CancellationTokenSource source) @@ -241,6 +234,17 @@ private static void Cancel(CancellationTokenSource source) } } + /// Claims completion before notifying the observer, excluding disposed subscriptions. + internal sealed class TaskStopGate + { + /// Non-zero once the continuation has emitted or the subscription has been disposed. + private int _stopped; + + /// Attempts to win the terminal transition. + /// when this caller won the stop race. + internal bool TryStop() => Interlocked.Exchange(ref _stopped, 1) == 0; + } + /// Task signal that starts the task in Subscribe rather than through a nested observable pipeline. /// The result type. private sealed class ImmediateTaskSignal : ITaskSignal @@ -344,19 +348,4 @@ private void ThrowIfDisposed() throw new ObjectDisposedException(nameof(ImmediateTaskSignal<>)); } } - - /// Atomic gate that serializes the terminal notification against subscription disposal. - /// - /// The task continuation and the disposer race on a single and only - /// the winner proceeds, so no notification reaches a disposed subscription. - /// - private sealed class TaskStopGate - { - /// Non-zero once the continuation has emitted or the subscription has been disposed. - private int _stopped; - - /// Attempts to win the terminal transition. - /// when this caller won the stop race. - public bool TryStop() => Interlocked.Exchange(ref _stopped, 1) == 0; - } } diff --git a/src/Primitives.Shared/Signals/TaskSignal{T}.cs b/src/Primitives.Shared/Signals/TaskSignal{T}.cs index 9e5d77f3..cfd1e872 100644 --- a/src/Primitives.Shared/Signals/TaskSignal{T}.cs +++ b/src/Primitives.Shared/Signals/TaskSignal{T}.cs @@ -65,15 +65,11 @@ public IDisposable Subscribe(IObserver observer) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => Dispose(true); - /// Creates a task-backed signal whose source the supplied factory builds. + /// Invokes the factory after the signal is fully initialized. /// Builds the source, receiving the signal it will belong to. /// The sequencer subscriptions are observed on. /// The cancellation source to observe. /// The created signal. - /// - /// The factory receives the signal and may subscribe to, dispose, or store it the moment it is called, so - /// construction finishes before the factory runs rather than calling it from the constructor. - /// internal static TaskSignal Create( Func, IObservable> observableFactory, ISequencer? sequencer = null, diff --git a/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs b/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs index 9eedb81e..b43d4a94 100644 --- a/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs +++ b/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs @@ -10,11 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Signals; namespace ReactiveUI.Primitives.Signals; #endif -/// -/// Owns the task source, the source subscription and the cancellation registration for task-returning terminals. -/// Callers wire their observer callbacks to / and hand the subscription to -/// , which registers for cancellation only while the task is pending. -/// +/// Owns terminal task completion, the source subscription, and cancellation registration while pending. /// The task result type. internal sealed class TaskTerminalCompletion { diff --git a/src/ReactiveUI.Disposables/Disposables/DisposableSlotHelper.cs b/src/ReactiveUI.Disposables/Disposables/DisposableSlotHelper.cs index 7c176fe4..fcec9ec7 100644 --- a/src/ReactiveUI.Disposables/Disposables/DisposableSlotHelper.cs +++ b/src/ReactiveUI.Disposables/Disposables/DisposableSlotHelper.cs @@ -2,33 +2,15 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace ReactiveUI.Primitives.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 RxVoid 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 the library's throw-helper methods. -/// +/// Assigns and disposes slots according to their holder's disposal state. internal static class DisposableSlotHelper { /// Sentinel value indicating the holder has been disposed. internal 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 - /// . - /// + /// Replaces a slot without disposing its previous value; a disposed holder disposes the incoming value. /// The reference to the current-inner field. /// The reference to the disposed-flag field. /// The incoming value (or ). @@ -47,12 +29,7 @@ internal static void AssignWithoutDisposingPrevious( 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 . - /// + /// Replaces a slot and disposes its previous value; a disposed holder also disposes the incoming value. /// The reference to the current-inner field. /// The reference to the disposed-flag field. /// The incoming value (or ). @@ -72,12 +49,7 @@ internal static void SwapAndDisposePrevious( 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. - /// + /// Claims disposal and releases the inner value, returning true only for the first caller. /// The reference to the current-inner field. /// The reference to the disposed-flag field. /// @@ -95,16 +67,10 @@ internal static bool TryDispose(ref IDisposable? slot, ref int disposed) 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 RxVoid tests, hence the exclusion. - /// + /// Releases the stored value if disposal overlaps assignment. /// 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) + internal static void DisposeIfRaced(ref IDisposable? slot, ref int disposed) { if (Volatile.Read(ref disposed) != DisposedSentinel) { diff --git a/src/ReactiveUI.Disposables/Disposables/Scope.cs b/src/ReactiveUI.Disposables/Disposables/Scope.cs index b6c917a9..2fb72c46 100644 --- a/src/ReactiveUI.Disposables/Disposables/Scope.cs +++ b/src/ReactiveUI.Disposables/Disposables/Scope.cs @@ -15,7 +15,7 @@ public static class Scope /// Creates a disposable object that invokes the specified action when disposed. /// Action to run during the first call to . The action is guaranteed to be run at most once. /// The disposable object that runs the given action upon disposal. - /// A action returns for backward compatibility with existing ReactiveUI.Primitives create pipelines. + /// A action returns . public static IDisposable Create(Action dispose) => dispose is null ? Empty : new ActionDisposable(dispose); diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs index 4e644a8e..43006eba 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs @@ -7,12 +7,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// Asynchronous mutual-exclusion primitive that serializes critical sections in the async pipeline. -/// Each acquire hands back a that releases the gate when disposed. Ownership is keyed -/// on the managed thread id, so a nested acquire on the holding thread is granted immediately and reentry -/// is recognised only while the caller stays on the thread that took the gate. -/// +/// Serializes asynchronous critical sections with disposable leases. Reentrant acquisition is allowed only on the managed thread that currently owns the gate. [System.Diagnostics.DebuggerDisplay("AsyncSerialGate: OwnerThreadId = {_ownerThreadId}, Waiters = {_waiters}, RecursionDepth = {_recursionDepth}")] public sealed class AsyncSerialGate : IDisposable { @@ -47,23 +42,10 @@ public ValueTask EnterAsync() => /// A that completes when the gate has been acquired. /// Thrown when the token is cancelled before the gate is /// acquired. + [MethodImpl(MethodImplOptions.AggressiveInlining)] [DebuggerStepThrough] - public ValueTask EnterAsync(CancellationToken cancellationToken) - { - var currentThreadId = Environment.CurrentManagedThreadId; - - // Same-thread reentry: the calling thread owns the gate, so bumping depth needs no synchronization. - if (Volatile.Read(ref _ownerThreadId) == currentThreadId) - { - _recursionDepth++; - return new(new Lease(this)); - } - - // Fast uncontended acquire: pure CAS, no semaphore touch. - return Interlocked.CompareExchange(ref _ownerThreadId, currentThreadId, 0) == 0 - ? new(new Lease(this)) - : WaitForEntryAsync(cancellationToken); - } + public ValueTask EnterAsync(CancellationToken cancellationToken) => + EnterForThreadAsync(Environment.CurrentManagedThreadId, cancellationToken); /// public void Dispose() @@ -76,40 +58,40 @@ public void Dispose() _semaphore.Dispose(); } - /// - /// Exits the gate. Decrements the recursion depth on a nested exit, or clears the owner - /// and signals one waiter (if any) on the outermost release. - /// - internal void Exit() + /// Acquires ownership for the supplied caller thread, allowing same-thread reentry. + /// The calling thread identifier. + /// Cancellation observed while waiting. + /// The acquired gate lease. + internal ValueTask EnterForThreadAsync(int currentThreadId, CancellationToken cancellationToken) { - if (_recursionDepth > 0) + if (Volatile.Read(ref _ownerThreadId) == currentThreadId) { - _recursionDepth--; - return; + _recursionDepth++; + return new(new Lease(this)); } - Volatile.Write(ref _ownerThreadId, 0); - WakeNextWaiter(); + return Interlocked.CompareExchange(ref _ownerThreadId, currentThreadId, 0) == 0 + ? new(new Lease(this)) + : WaitForEntryAsync(cancellationToken); } - /// - /// Signals one parked waiter if any are present. A signal released after the last waiter has left - /// lands in the semaphore count and is consumed by the next waiter to arrive. - /// - private void WakeNextWaiter() + /// Exits the gate. Decrements the recursion depth on a nested exit, or clears the owner and signals one waiter (if any) on the outermost release. + internal void Exit() { - if (Volatile.Read(ref _waiters) == 0) + if (_recursionDepth > 0) { + _recursionDepth--; return; } - _ = _semaphore.Release(); + Volatile.Write(ref _ownerThreadId, 0); + WakeNextWaiter(); } /// Slow path: park as a waiter and retry the acquire CAS after each semaphore signal. /// Cancellation token observed while waiting. /// A for the acquired gate. - private async ValueTask WaitForEntryAsync(CancellationToken cancellationToken) + internal async ValueTask WaitForEntryAsync(CancellationToken cancellationToken) { _ = Interlocked.Increment(ref _waiters); try @@ -132,6 +114,17 @@ private async ValueTask WaitForEntryAsync(CancellationToken cancellationT } } + /// Signals one parked waiter if any are present; a late signal is consumed by the next waiter. + private void WakeNextWaiter() + { + if (Volatile.Read(ref _waiters) == 0) + { + return; + } + + _ = _semaphore.Release(); + } + /// Holds one acquisition of an and releases it on disposal. [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : IDisposable diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs index cdba6c72..4e1a6b1f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs @@ -53,8 +53,7 @@ public FlatMapCoordinator(IObserverAsync observer) /// Gets or sets a value indicating whether this coordinator has been disposed. private bool Disposed { get; set; } - /// Takes ownership of the outer subscription, disposing it immediately when this coordinator - /// has finished. Callable once per coordinator. + /// Takes ownership of the outer subscription, disposing it immediately when this coordinator has finished. Callable once per coordinator. /// The outer subscription to own. /// A task that completes once the subscription has been stored or disposed. /// Thrown when an outer subscription is set twice. @@ -117,8 +116,7 @@ public async ValueTask SubscribeInnerAsync(IObservableAsync inner) } } - /// Forwards an inner value downstream, serialized against the other inner sequences so the observer - /// is never entered concurrently. + /// Forwards an inner value downstream, serialized against the other inner sequences so the observer is never entered concurrently. /// The value. /// A task that completes once the observer has accepted the value. public async ValueTask RelayNextAsync(TResult value) @@ -129,8 +127,7 @@ public async ValueTask RelayNextAsync(TResult value) } } - /// Forwards a non-terminal error downstream under the same serialization as values, leaving the - /// sequence running. + /// Forwards a non-terminal error downstream under the same serialization as values, leaving the sequence running. /// The error. /// A task that completes once the observer has accepted the error. public async ValueTask RelayErrorAsync(Exception error) @@ -141,8 +138,7 @@ public async ValueTask RelayErrorAsync(Exception error) } } - /// Records outer completion, finishing the sequence when the result is a failure or no inner - /// sequence is active, and otherwise waiting for the active inner sequences. + /// Records outer completion, finishing the sequence when the result is a failure or no inner sequence is active, and otherwise waiting for the active inner sequences. /// The completion result. /// A task that completes once the sequence has finished, or immediately when it continues. public ValueTask CompleteOuterAsync(Result result) @@ -157,8 +153,7 @@ public ValueTask CompleteOuterAsync(Result result) return shouldComplete ? FinishAsync(result) : default; } - /// Records one inner sequence finishing, completing the sequence when the result is a failure or - /// when the outer source has completed and no inner sequence remains. + /// Records one inner sequence finishing, completing the sequence when the result is a failure or when the outer source has completed and no inner sequence remains. /// The completion result. /// A task that completes once the sequence has finished, or immediately when it continues. public ValueTask CompleteInnerAsync(Result result) @@ -177,8 +172,7 @@ public ValueTask CompleteInnerAsync(Result result) return shouldComplete ? FinishAsync(result) : default; } - /// Completes the downstream observer and disposes every tracked subscription. Idempotent; a failure - /// handed in after teardown goes to the unhandled exception handler. + /// Completes the downstream observer and disposes every tracked subscription. Idempotent; a failure handed in after teardown goes to the unhandled exception handler. /// The result to forward, or to tear down without completing /// the observer. /// A task that completes once teardown has finished. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs index 79580141..7d7b45b5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/IReentrantAsyncDisposable.cs @@ -5,9 +5,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// An async-disposable that can be disposed from within its own in-flight notification. -/// A terminal sink disposes itself from inside the OnNext/OnCompleted call that produced -/// its result, which cannot serve: that path waits for in-flight calls -/// to drain, and the notification awaiting the dispose is one of them. This entry point skips the self-join. +/// Disposal from inside an observer callback must not wait for that same callback to finish. public interface IReentrantAsyncDisposable { /// Disposes from within the object's own in-flight notification, skipping the in-flight-call wait. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs index 0dba9c7d..7be36594 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs @@ -2,17 +2,13 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading.Tasks.Sources; using ReactiveUI.Primitives.Internal; namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// Poolable backing DelayAsync for non-System -/// instances, so a delay costs no per-call allocation. -/// +/// Poolable backing DelayAsync for non-System instances, so a delay costs no per-call allocation. /// /// Whichever of the timer callback and the cancellation registration fires first claims completion /// through an on a state flag; the @@ -27,11 +23,7 @@ public sealed class PooledDelaySource : IValueTaskSource /// State value for meaning "either timer or cancellation claimed completion". private const int StateClaimed = 1; - /// - /// Per-thread cached instance; one slot per thread suffices because the operators that consume - /// DelayAsync serialise their work behind a gate, so a thread holds at most one - /// in-flight delay per operator. - /// + /// One reusable delay source per thread; concurrent rentals allocate when this slot is empty. [ThreadStatic] private static PooledDelaySource? _threadCached; @@ -135,7 +127,6 @@ public void GetResult(short token) } /// Completes the delay successfully when the timer's dueTime elapses, unless cancellation claimed it first. - [ExcludeFromCodeCoverage] private void OnTimerFired() { if (!ConcurrencyRaceHelpers.TryClaim(ref _completed, StateOpen, StateClaimed)) @@ -148,7 +139,6 @@ private void OnTimerFired() /// Faults the delay with when the caller's token fires, unless the timer claimed it first. /// The cancellation token that fired. - [ExcludeFromCodeCoverage] private void OnCancelled(CancellationToken cancellationToken) { if (!ConcurrencyRaceHelpers.TryClaim(ref _completed, StateOpen, StateClaimed)) diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs index f6d753ca..5eacb0fe 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs @@ -55,11 +55,7 @@ internal ValueTask RelaySourceErrorAsync(Exception error, CancellationToken canc return Lifecycle.OnErrorResumeAsync(error); } - /// - /// Projects the latest-value slots through the selector and forwards the result downstream, doing - /// nothing until every source has produced a value. Called after a per-source OnNext has landed - /// under . - /// + /// Projects the latest values once every source has emitted. Called after updating a slot under ValuesLock. /// A ValueTask representing the asynchronous emit. internal abstract ValueTask EmitLatestAsync(); diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestWitness{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestWitness{TSource,TResult}.cs index 8f9d327b..18377710 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestWitness{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestWitness{TSource,TResult}.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// Per-source observer used by arity-specific SyncLatest coordinators to record typed source values -/// and forward source terminal notifications to the shared lifecycle. -/// +/// Per-source observer used by arity-specific SyncLatest coordinators to record typed source values and forward source terminal notifications to the shared lifecycle. /// The source element type. /// The downstream element type. /// The parent coordinator. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs index 2850efc3..4c9bfb5c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilSourceWitness.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// that forwards every source notification straight into a -/// , which gates it on its way downstream. -/// +/// that forwards every source notification straight into a , which gates it on its way downstream. /// The downstream element type. /// The shared lifecycle owning the gate and forwarding logic. public sealed class TakeUntilSourceWitness(TakeUntilLifecycle lifecycle) : WitnessAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs index bee366c6..1d93ba50 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// A subscription that runs a cancellable asynchronous job feeding a single observer, and joins that -/// job on disposal. -/// +/// A subscription that runs a cancellable asynchronous job feeding a single observer, and joins that job on disposal. /// The type of the elements observed by the subscription. /// The observer that receives notifications for the subscription. Cannot be null. /// Disposal cancels the running job and waits for it to finish before releasing resources; diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TimerSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TimerSignal.cs index 8186290e..f0addbeb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TimerSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TimerSignal.cs @@ -20,10 +20,10 @@ public TimerSignal(TimeSpan dueTime, TimeSpan? period, TimeProvider timeProvider } /// Gets the delay before the first tick. - private TimeSpan DueTime { get; } + internal TimeSpan DueTime { get; } /// Gets the optional delay between subsequent ticks. - private TimeSpan? Period { get; } + internal TimeSpan? Period { get; } /// Gets the time provider used for scheduling. private TimeProvider TimeProvider { get; } diff --git a/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs index 10726809..56a27e57 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConcurrencyRaceHelpers.cs @@ -4,16 +4,10 @@ namespace ReactiveUI.Primitives.Internal; -/// -/// Pure race-claim helpers shared by the async layer: a first-caller-wins sentinel transition, and a cancel call that -/// treats a concurrently disposed token source as a lost race rather than a failure. -/// +/// Atomic ownership claims and cancellation tolerant of prior disposal. internal static class ConcurrencyRaceHelpers { - /// - /// Atomically transitions from to - /// . - /// + /// Atomically transitions from to . /// The reference to the state field. /// The sentinel value the state must currently hold. /// The sentinel value the state transitions to on success. @@ -23,9 +17,7 @@ internal static class ConcurrencyRaceHelpers internal static bool TryClaim(ref int state, int openSentinel, int claimedSentinel) => Interlocked.CompareExchange(ref state, claimedSentinel, openSentinel) == openSentinel; - /// - /// Cancels , swallowing the a racing dispose raises. - /// + /// Cancels , swallowing the a racing dispose raises. /// The cancellation token source to cancel. /// /// when the cancellation completed; when a concurrent caller had diff --git a/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs b/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs index 5dde5678..95bfda91 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs @@ -8,10 +8,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// The exception that is thrown when multiple concurrent calls are made to observer methods that do not support -/// concurrent execution. -/// +/// The exception that is thrown when multiple concurrent calls are made to observer methods that do not support concurrent execution. /// serializes OnNextAsync, OnErrorResumeAsync and /// OnCompletedAsync; this exception reports one of those calls arriving while another is in flight. Await each /// call to completion before starting the next. diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs index f2ff57d1..a719cb1f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs @@ -8,10 +8,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// An asynchronous observable that multicasts a cold source through a signal, leaving the caller to decide when the -/// source subscription is established. -/// +/// An asynchronous observable that multicasts a cold source through a signal, leaving the caller to decide when the source subscription is established. /// The type of elements produced by the observable sequence. /// Subscribing attaches the observer to the signal without touching the source, so observers that subscribe /// before all share the one source subscription it creates. @@ -27,9 +24,7 @@ public ConnectableSignalAsync(IObservableAsync source, ISignalAsync signal /// Gets the mutable connection state owned by this wrapper. private ConnectableSignalAsyncState State { get; } - /// - /// Subscribes the signal to the source, or returns the live connection when one exists. - /// + /// Subscribes the signal to the source, or returns the live connection when one exists. /// A cancellation token that can be used to cancel the asynchronous connection operation. /// A handle whose disposal drops the source subscription, allowing a later call to reconnect. /// This instance has been disposed, or diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs index bf6edcb5..885d4ee3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs @@ -22,8 +22,7 @@ internal static async ValueTask ConnectAsync( ConnectableSignalAsyncState state, CancellationToken cancellationToken) { - // A disposed signal cancels this token, and the gate's uncontended fast path does not observe - // cancellation, so without this check a post-disposal connect would resubscribe the cold source. + // The gate's fast path does not check cancellation; reject disposed signals before entering. state.DisposedCancellationToken.ThrowIfCancellationRequested(); CancellationTokenSource? linkedCts = null; diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs index edc1db4e..7eb311f4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs @@ -52,10 +52,7 @@ internal sealed class DelegateAsyncDisposable(Func disposeAsync) : IA public ValueTask DisposeAsync() => Interlocked.Exchange(ref _disposed, 1) == 1 ? default : disposeAsync(); } - /// - /// An asynchronous disposable that invokes a delegate with a stored state when disposed, so the caller's data - /// travels in instead of a closure. - /// + /// An asynchronous disposable that invokes a delegate with a stored state when disposed, so the caller's data travels in instead of a closure. /// The type of the state passed to the dispose delegate. /// The state forwarded to the dispose delegate at dispose time. /// The delegate invoked to perform asynchronous disposal. diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs index 602cc1fb..02b68d82 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; namespace ReactiveUI.Primitives.Async.Disposables; @@ -27,26 +26,9 @@ public static class DisposableAsyncSlot /// The new value to store, or to clear the slot. /// A that completes once the previous occupant (if any) has been disposed. [DebuggerStepThrough] - [ExcludeFromCodeCoverage] - public static ValueTask SwapAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value) - { - var current = Volatile.Read(ref slot); - while (true) - { - if (ReferenceEquals(current, DisposedSentinel)) - { - return value?.DisposeAsync() ?? default; - } - - var exchanged = Interlocked.CompareExchange(ref slot, value, current); - if (ReferenceEquals(exchanged, current)) - { - return current?.DisposeAsync() ?? default; - } - - current = exchanged; - } - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask SwapAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value) => + SwapObservedAsync(ref slot, value, Volatile.Read(ref slot)); /// Atomically fills an empty slot with . A closed slot disposes /// instead. Equivalent to @@ -90,6 +72,30 @@ public static ValueTask DisposeAsync(ref IAsyncDisposable? slot) public static bool IsDisposed(IAsyncDisposable? slot) => ReferenceEquals(slot, DisposedSentinel); + /// Retries a swap when the slot changed after the caller's observation. + /// The slot to replace. + /// The incoming disposable. + /// The previously observed slot value. + /// Disposal of the replaced value, or the incoming value when the slot is closed. + internal static ValueTask SwapObservedAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value, IAsyncDisposable? current) + { + while (true) + { + if (ReferenceEquals(current, DisposedSentinel)) + { + return value?.DisposeAsync() ?? default; + } + + var exchanged = Interlocked.CompareExchange(ref slot, value, current); + if (ReferenceEquals(exchanged, current)) + { + return current?.DisposeAsync() ?? default; + } + + current = exchanged; + } + } + /// Creates the exception for a second assignment into a single-assignment slot. /// The invalid-operation exception to throw from the assignment path. internal static InvalidOperationException CreateAlreadyAssignedException() => diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs index dfe94093..ce047d27 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs @@ -27,8 +27,7 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable /// Factor the backing array's capacity is multiplied by when it overflows. private const int GrowthFactor = 2; - /// Divisor applied to the backing array's capacity when a sparse collection is compacted; safe because - /// compaction only runs below quarter occupancy. + /// Divisor applied to the backing array's capacity when a sparse collection is compacted; safe because compaction only runs below quarter occupancy. private const int CompactionShrinkDivisor = 2; /// The synchronization gate protecting all mutable state in this collection. @@ -49,10 +48,7 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable /// Indicates whether the collection has been disposed. private bool _isDisposed; - /// - /// Initializes a new instance of the class, allocating its backing array on - /// the first call. - /// + /// Initializes a new instance of the class, allocating its backing array on the first call. public MultipleDisposableAsync() { } @@ -67,10 +63,7 @@ public MultipleDisposableAsync(int capacity) _items = capacity == 0 ? null : new IAsyncDisposable?[capacity]; } - /// - /// Initializes a new instance of the class that contains the specified - /// disposables, sizing the backing array exactly. - /// + /// Initializes a new instance of the class that contains the specified disposables, sizing the backing array exactly. /// An array of objects implementing . public MultipleDisposableAsync(params IAsyncDisposable[] disposables) { @@ -145,9 +138,7 @@ public int Count } } - /// - /// Takes ownership of a disposable, disposing it on the spot when this collection has been disposed. - /// + /// Takes ownership of a disposable, disposing it on the spot when this collection has been disposed. /// The item whose lifetime this collection takes over. Cannot be null. /// A completed task when the item was stored; otherwise the task disposing it. public ValueTask AddAsync(IAsyncDisposable item) @@ -296,10 +287,7 @@ public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) } } - /// - /// Asynchronously releases all resources used by the collection and disposes of each contained asynchronous - /// disposable object. - /// + /// Asynchronously releases all resources used by the collection and disposes of each contained asynchronous disposable object. /// A task that represents the asynchronous dispose operation. /// Idempotent. Items are disposed one after another in insertion order. public async ValueTask DisposeAsync() @@ -396,8 +384,7 @@ private void CopyToCore(IAsyncDisposable[] array, int arrayIndex) } } - /// Ensures has at least one free slot at index . - /// Allocates the default-capacity array on first use; doubles on subsequent overflow. + /// Ensures has at least one free slot at index . Allocates the default-capacity array on first use; doubles on subsequent overflow. private void EnsureCapacityForOneMore() { if (_items is null) diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs index 4297e435..2daec78b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs @@ -31,8 +31,7 @@ public sealed class SingleAssignmentDisposableAsync : IAsyncDisposable return DisposableAsyncSlot.IsDisposed(field) ? DisposableAsync.Empty : field; } - /// Assigns the resource this instance owns, disposing on the spot when this - /// instance has been disposed. + /// Assigns the resource this instance owns, disposing on the spot when this instance has been disposed. /// The to take ownership of, or . /// A that completes once any disposal this call triggered has finished. /// A resource has been assigned by an earlier call. diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs index b23dcbb7..646260bc 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async.Disposables; -/// -/// Provides a thread-safe mechanism for managing a single asynchronously disposable resource that can be replaced or -/// disposed of serially. -/// +/// Provides a thread-safe mechanism for managing a single asynchronously disposable resource that can be replaced or disposed of serially. /// Exactly one resource is live at a time: each assignment disposes the one it displaces, and disposing this /// instance closes the slot so a further assignment disposes its argument. Safe to use from several threads. [System.Diagnostics.DebuggerDisplay("SingleReplaceableDisposableAsync: Current = {_current}")] @@ -18,10 +15,7 @@ public class SingleReplaceableDisposableAsync : IAsyncDisposable /// The tracked disposable, or the sentinel that marks the slot closed. private IAsyncDisposable? _current; - /// - /// Takes over from the tracked resource, disposing the one it displaces — or disposing - /// itself when this instance has been disposed. - /// + /// Takes over from the tracked resource, disposing the one it displaces — or disposing itself when this instance has been disposed. /// The new instance to track. Can be to clear the current /// resource. /// A that completes once whichever resource this call gave up has been @@ -32,7 +26,6 @@ public ValueTask SetDisposableAsync(IAsyncDisposable? value) => /// Disposes the tracked resource and closes the slot. /// A that completes once the tracked resource has been disposed. - /// Idempotent. public ValueTask DisposeAsync() { GC.SuppressFinalize(this); diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs index 5e3c1a65..8b92f678 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/DisposalHelper.cs @@ -2,13 +2,11 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; namespace ReactiveUI.Primitives.Async.Helpers; /// Provides helper methods for idempotent disposal patterns using an integer flag. -[ExcludeFromCodeCoverage] public static class DisposalHelper { /// Checks whether the disposed flag indicates disposal has occurred. diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs index 01fab590..419e96b7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs @@ -10,7 +10,6 @@ namespace ReactiveUI.Primitives.Async.Helpers; /// Runs asynchronous work from a synchronous callback — a cancellation-token registration, a signal handler — where /// there is no caller to observe a task, reporting failures to . /// -[ExcludeFromCodeCoverage] public static class FireAndForgetHelper { /// Executes an async action as fire-and-forget, swallowing all exceptions. @@ -23,7 +22,14 @@ public static class FireAndForgetHelper "ReSharper", "AsyncVoidMethod", Justification = "This is a fire-and-forget helper.")] - public static async void Run(Func action) + [ExcludeFromCodeCoverage] + public static async void Run(Func action) => + await RunAsync(action).ConfigureAwait(false); + + /// Runs the action and reports failures to the unhandled exception handler. + /// The action to execute. + /// The action and any exception reporting. + internal static async ValueTask RunAsync(Func action) { ArgumentExceptionHelper.ThrowIfNull(action); diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs index e8f50932..3a7728b5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/SubscriptionHelper.cs @@ -4,14 +4,10 @@ namespace ReactiveUI.Primitives.Async.Helpers; -/// -/// Provides a helper for safely subscribing an subscription, -/// ensuring the subscription is disposed if the subscribe action throws. -/// +/// Provides a helper for safely subscribing an subscription, ensuring the subscription is disposed if the subscribe action throws. public static class SubscriptionHelper { - /// Runs , disposing and rethrowing if it - /// fails, so a half-built subscription is never handed back. + /// Runs , disposing and rethrowing if it fails, so a half-built subscription is never handed back. /// The subscription to manage. /// The async action that wires up the subscription. /// The subscription, once wiring succeeded. diff --git a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs index 1bef1a1c..f2316798 100644 --- a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Defines an asynchronous observer that receives notifications about a sequence of values, completion, or errors, and -/// supports asynchronous resource cleanup. -/// +/// Defines an asynchronous observer that receives notifications about a sequence of values, completion, or errors, and supports asynchronous resource cleanup. /// The type of the elements received by the observer. /// Every notification is awaitable and cancellable, so a producer that awaits them gets backpressure for /// free: the observer's handler has to finish before the next value is pushed. diff --git a/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs b/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs index 7dc5b4b0..5dd42739 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Mixins/DisposableAsyncExtensions.cs @@ -29,10 +29,7 @@ public IAsyncDisposable ToDisposableAsync() } } - /// - /// Presents a synchronous as an , calling - /// inline and completing synchronously. - /// + /// Presents a synchronous as an , calling inline and completing synchronously. /// The instance to be wrapped for asynchronous disposal. Cannot be null. internal sealed class DisposableToDisposableAsync(IDisposable disposable) : IAsyncDisposable { diff --git a/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs b/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs index 6979bd74..46361992 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs @@ -29,10 +29,7 @@ public IObserverAsync AsObserverAsync() return new SignalAsyncWitness(source); } - /// - /// Creates a new signal that applies a transformation to the values of the source signal using the specified - /// mapping function. - /// + /// Creates a new signal that applies a transformation to the values of the source signal using the specified mapping function. /// A function that takes an asynchronous observable of type T and returns a transformed asynchronous observable of /// type T. This function defines how the values are mapped. /// A signal that publishes into but exposes the mapped sequence to its own @@ -51,8 +48,8 @@ public ISignalAsync MapValues(Func, IObservableAsync> /// A signal that applies a transformation to the observable values of the source signal. /// The type of elements processed by the signal. /// The source signal. - /// A function that takes an asynchronous observable of type T and returns a transformed asynchronous observable of - /// type T. This function defines how the values are mapped. + /// Transforms the source's value sequence + /// once during construction. internal sealed class MappedSignal( ISignalAsync original, Func, IObservableAsync> mapper) : ISignalAsync @@ -91,11 +88,7 @@ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellat /// The signal to forward notifications to. internal sealed class SignalAsyncWitness(ISignalAsync signal) : WitnessAsync { - /// - /// Forwards the value to the wrapped signal under , which every downstream - /// takes its no-link fast path on. Passing this observer's own dispose token - /// instead would buy nothing: disposal stops values from reaching this method at all. - /// + /// Forwards the value with an uncancelable token after the observer's disposal check. /// The value to be processed by the observer. /// The token captured by the base observer's TryEnter scope. Ignored on the forward. /// A ValueTask that represents the asynchronous operation. diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs index fe75b5d9..56ce4820 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides factory methods for creating asynchronous observables and background jobs that emit values to observers -/// asynchronously. -/// +/// Provides factory methods for creating asynchronous observables and background jobs that emit values to observers asynchronously. /// These are the entry points for turning an arbitrary asynchronous producer into a sequence: supply the /// subscribe logic yourself, or hand over a job to run per subscriber. public static partial class SignalAsync @@ -52,10 +49,7 @@ public static IObservableAsync CreateAsBackgroundJob( bool startSynchronously) => new BackgroundJobSignal(job, startSynchronously, null); - /// - /// Creates a new observable sequence that runs the specified asynchronous job as a background task using the - /// provided task scheduler. - /// + /// Creates a new observable sequence that runs the specified asynchronous job as a background task using the provided task scheduler. /// The type of the elements produced by the observable sequence. /// A delegate that defines the asynchronous job to execute. The delegate receives an observer to report results and /// a cancellation token to observe cancellation requests. diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs index 15672725..b96d585a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Defer.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides factory methods for creating observable sequences that are generated by invoking a specified function each -/// time a new observer subscribes. -/// +/// Provides factory methods for creating observable sequences that are generated by invoking a specified function each time a new observer subscribes. /// Nothing runs at declaration: the factory is called once per subscriber, so each one gets a sequence built /// from the state that holds at its own subscribe time. public static partial class SignalAsync @@ -24,10 +21,7 @@ public static partial class SignalAsync public static IObservableAsync Defer(Func>> factory) => new DeferAsyncSignalAsync(factory); - /// - /// Returns an observable sequence that is created by invoking the specified factory function each time a new - /// observer subscribes. - /// + /// Returns an observable sequence that is created by invoking the specified factory function each time a new observer subscribes. /// The type of the elements produced by the observable sequence. /// A function that returns a new instance of an observable sequence to be subscribed to for each observer. /// An observable sequence that calls on each subscribe and subscribes to @@ -36,8 +30,7 @@ public static IObservableAsync Defer(Func Defer(Func> factory) => new DeferSyncSignalAsync(factory); - /// Backs : each subscribe calls the factory and hands the - /// wrapped observer to the sequence it returns. + /// Backs : each subscribe calls the factory and hands the wrapped observer to the sequence it returns. /// The element type. /// The deferred factory invoked once per subscribe. internal sealed class DeferSyncSignalAsync(Func> factory) : IObservableAsync @@ -50,9 +43,7 @@ ValueTask IObservableAsync.SubscribeAsync( factory().SubscribeAsync(observer.Wrap(), cancellationToken); } - /// Backs the -returning - /// , awaiting the factory before it - /// can subscribe. + /// Backs the -returning , awaiting the factory before it can subscribe. /// The element type. /// The deferred factory invoked once per subscribe. internal sealed class DeferAsyncSignalAsync(Func>> factory) : IObservableAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs index 7b475e0f..b36b582c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs @@ -32,10 +32,7 @@ public static partial class SignalAsync Justification = "There are no parameters to infer from; the caller states the element type: SignalAsync.Empty().")] public static IObservableAsync Empty() => EmptySignalAsync.Instance; - /// - /// Signals successful completion on subscribe and hands back , from a cached - /// instance per element type. - /// + /// Signals successful completion on subscribe and hands back , from a cached instance per element type. /// The element type of the empty sequence. internal sealed class EmptySignalAsync : IObservableAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs index 448eda43..ee4627d0 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs @@ -29,10 +29,7 @@ public static partial class SignalAsync Justification = "Return is the Rx-compatible alias for Emit and builds the same signal with no forwarding hop.")] public static IObservableAsync Return(T value) => new ReturnSignalAsync(value); - /// - /// Holds the single value in a field and emits it from a started per - /// subscriber, so the emit is deferred without a delegate to close over. - /// + /// Defers one value per subscriber without allocating a closure. /// The element type emitted. /// The captured value emitted on each subscribe. internal sealed class ReturnSignalAsync(T value) : IObservableAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs index ec2324dc..d8206c9c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides the clock-driven factory methods: a one-shot value after a delay, and periodic ticks. -/// +/// Provides the clock-driven factory methods: a one-shot value after a delay, and periodic ticks. public static partial class SignalAsync { /// Creates an observable sequence that produces a single value (0) after the specified delay, then completes. @@ -22,10 +20,7 @@ public static IObservableAsync After(TimeSpan dueTime) return new TimerSignal(dueTime, null, TimeProvider.System); } - /// - /// Creates an observable sequence that produces a single value (0) after the specified delay, - /// then continues to produce sequential values at each specified period. - /// + /// Creates an observable sequence that produces a single value (0) after the specified delay, then continues to produce sequential values at each specified period. /// The initial delay before the first value is produced. Must be non-negative. /// The interval between subsequent values after the initial delay. Must be positive. /// An observable sequence that produces values starting after the initial delay and continuing @@ -77,10 +72,7 @@ public static IObservableAsync Timer(TimeSpan dueTime, TimeProvider? timeP return new TimerSignal(dueTime, null, timeProvider ?? TimeProvider.System); } - /// - /// Creates an observable sequence that produces a single value (0) after the specified delay, - /// then continues to produce sequential values at each specified period. - /// + /// Creates an observable sequence that produces a single value (0) after the specified delay, then continues to produce sequential values at each specified period. /// The initial delay before the first value is produced. Must be non-negative. /// The interval between subsequent values after the initial delay. Must be positive. /// An observable sequence that produces values starting after the initial delay and continuing @@ -90,10 +82,7 @@ public static IObservableAsync Timer(TimeSpan dueTime, TimeProvider? timeP [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Timer(TimeSpan dueTime, TimeSpan period) => After(dueTime, period); - /// - /// Creates an observable sequence that produces a single value (0) after the specified delay, - /// then continues to produce sequential values at each specified period. - /// + /// Creates an observable sequence that produces a single value (0) after the specified delay, then continues to produce sequential values at each specified period. /// The initial delay before the first value is produced. Must be non-negative. /// The interval between subsequent values after the initial delay. Must be positive. /// An optional time provider for controlling timing. If null, diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs index 3017f5da..3f82bda5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides extension methods for converting tasks, asynchronous enumerables, and enumerable sequences into -/// asynchronous observable sequences. -/// +/// Provides extension methods for converting tasks, asynchronous enumerables, and enumerable sequences into asynchronous observable sequences. /// Every overload is spelled ToAsyncSignal, so the conversion reads the same whatever the source, and a /// sequence that is an passes straight through. public static partial class SignalAsyncExtensions @@ -36,10 +33,7 @@ public static partial class SignalAsyncExtensions /// The enumerable sequence to convert to an asynchronous observable. Cannot be null. extension(IEnumerable source) { - /// - /// Converts the specified enumerable sequence to an asynchronous observable sequence, emitting each element in the - /// background. - /// + /// Converts the specified enumerable sequence to an asynchronous observable sequence, emitting each element in the background. /// An asynchronous observable sequence that emits each element from the source enumerable and completes when all /// elements have been emitted. /// Enumeration runs on a background thread, once per subscriber, so a blocking or side-effecting @@ -57,8 +51,7 @@ public static partial class SignalAsyncExtensions /// The source sequence. extension(IObservableAsync source) { - /// Null-checks and returns the source unchanged, so generic code can convert without knowing whether - /// it has a signal already. + /// Null-checks and returns the source unchanged, so generic code can convert without knowing whether it has a signal already. /// The same sequence. /// is . [SuppressMessage( @@ -74,10 +67,7 @@ public IObservableAsync ToAsyncSignal() => /// The task to convert to an asynchronous observable sequence. Cannot be null. extension(Task task) { - /// - /// Converts a task representing a single asynchronous value into an observable sequence that emits the result when - /// the task completes. - /// + /// Converts a task representing a single asynchronous value into an observable sequence that emits the result when the task completes. /// An asynchronous observable sequence that emits the result of the task when it completes, followed by a /// completion notification. /// A faulted or cancelled task terminates the sequence with that error. Since the task is a single diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs index f196f0d5..81174018 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs @@ -16,10 +16,7 @@ public static partial class SignalAsyncExtensions /// A collection of asynchronous observable sequences to be merged. extension(IEnumerable> sources) { - /// - /// Combines multiple asynchronous observable sequences into a single observable sequence that emits items from all - /// source sequences as they arrive. - /// + /// Combines multiple asynchronous observable sequences into a single observable sequence that emits items from all source sequences as they arrive. /// An observable sequence that emits items from all input sequences as they are produced. /// Emissions interleave as the sources produce them; the result completes once every source has /// completed, and an error from any source propagates and terminates it. @@ -27,10 +24,7 @@ public static partial class SignalAsyncExtensions public IObservableAsync Blend() => new BlendEnumerableSignal(sources); - /// - /// Combines multiple asynchronous observable sequences into a single observable sequence that emits items from all - /// source sequences as they arrive. - /// + /// Combines multiple asynchronous observable sequences into a single observable sequence that emits items from all source sequences as they arrive. /// An observable sequence that emits items from all input sequences as they are produced. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Merge() => @@ -43,10 +37,7 @@ public IObservableAsync Merge() => /// Cannot be null. extension(IObservableAsync> source) { - /// - /// Merges multiple asynchronous observable sequences into a single observable sequence that emits items from all - /// inner sequences as they arrive. - /// + /// Merges multiple asynchronous observable sequences into a single observable sequence that emits items from all inner sequences as they arrive. /// An asynchronous observable sequence that emits items from all inner observable sequences as they are produced. /// Every inner sequence stays subscribed at once; the result completes only after the outer sequence /// and all inner sequences have completed, and an error from any of them propagates and terminates it. @@ -54,19 +45,13 @@ public IObservableAsync Merge() => public IObservableAsync Blend() => new BlendSignalSourcesSignal(source); - /// - /// Merges multiple asynchronous observable sequences into a single observable sequence that emits items from all - /// inner sequences as they arrive. - /// + /// Merges multiple asynchronous observable sequences into a single observable sequence that emits items from all inner sequences as they arrive. /// An asynchronous observable sequence that emits items from all inner observable sequences as they are produced. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Merge() => new BlendSignalSourcesSignal(source); - /// - /// Merges the emissions of multiple asynchronous observable sequences into a single observable sequence, limiting - /// the number of concurrent subscriptions. - /// + /// Merges the emissions of multiple asynchronous observable sequences into a single observable sequence, limiting the number of concurrent subscriptions. /// The maximum number of inner observable sequences to subscribe to concurrently. /// An observable sequence that emits the items from the merged inner observable sequences. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -230,16 +215,14 @@ internal void LinkExternalCancellation(CancellationToken external) _disposeCts); } - /// Re-checks the disposed flag inside the serialization gate and forwards the value downstream if the - /// subscription is alive. + /// Re-checks the disposed flag inside the serialization gate and forwards the value downstream if the subscription is alive. /// The value to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayNextIfActiveAsync(T value) => DisposalHelper.HasDisposed(_disposed) ? default : _observer.OnNextAsync(value, DisposedCancellationToken); - /// Re-checks the disposed flag inside the serialization gate and forwards the error downstream if the - /// subscription is alive. + /// Re-checks the disposed flag inside the serialization gate and forwards the error downstream if the subscription is alive. /// The error to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayErrorIfActiveAsync(Exception exception) => DisposalHelper.HasDisposed(_disposed) @@ -411,10 +394,7 @@ internal override async ValueTask SubscribeBranchAsync(IObservableAsync inner } finally { - // On success the observer owns its semaphore slot and releases it on its own disposal - // (auto-dispose after OnCompletedAsync, or via parent FinishAsync). On failure we dispose - // the observer here so its idempotent CleanupBranchAsync returns the slot exactly once, - // regardless of whether the observer also gets disposed again through _innerDisposables. + // The observer releases its semaphore slot once; failed subscriptions also dispose it here. if (!subscribed) { await innerObserver.DisposeAsync().ConfigureAwait(false); @@ -430,10 +410,7 @@ internal override BlendBranchWitness CreateBranchObserver() => /// The parent bounded merge coordinator whose semaphore slot is released on disposal. internal sealed class BlendBranchWitnessWithPermit(BoundedBlendCoordinator parent) : BlendBranchWitness(parent) { - /// Guards the one-time release of this witness's semaphore slot. - /// can run several times for one witness, and a repeat - /// would exceed the semaphore's count and throw - /// , breaking the parent's completion chain. + /// Releases the semaphore once for this witness. private int _released; /// @@ -519,8 +496,7 @@ public BlendSequenceCoordinator(IObserverAsync observer, IEnumerable FinishAsync(null); - /// Routes the exception of a completion result that arrives after disposal to the unhandled - /// exception handler. + /// Routes the exception of a completion result that arrives after disposal to the unhandled exception handler. /// The completion result, or null if disposing without signaling. internal static void RoutePostDisposalException(Result? result) { @@ -543,9 +519,7 @@ internal void BeginSubscribing() => FireAndForgetHelper.Run(async () => _reentrant.Value = true; try { - // Sentinel: prevents premature completion while the loop is subscribing to sources. - // Without this, a synchronously-completing source (e.g. Return) can decrement _active - // to zero before the next source is subscribed, terminating the merge early. + // Hold a sentinel while subscribing so synchronous sources cannot complete the merge early. _ = Interlocked.Increment(ref _active); foreach (var src in _sources) @@ -627,8 +601,7 @@ internal async ValueTask RelayNextAsync(T value, CancellationToken token) } } - /// Re-checks the disposed flag inside the serialization gate and forwards the value downstream if - /// the subscription is alive. + /// Re-checks the disposed flag inside the serialization gate and forwards the value downstream if the subscription is alive. /// The value to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayNextIfActiveAsync(T value) => DisposalHelper.HasDisposed(_disposed) @@ -653,8 +626,7 @@ internal async ValueTask RelayErrorAsync(Exception ex, CancellationToken token) } } - /// Re-checks the disposed flag inside the serialization gate and forwards the error downstream if - /// the subscription is alive. + /// Re-checks the disposed flag inside the serialization gate and forwards the error downstream if the subscription is alive. /// The error to forward. /// A task representing the asynchronous forward operation. internal ValueTask RelayErrorIfActiveAsync(Exception ex) => DisposalHelper.HasDisposed(_disposed) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs index b09f9e00..82412d11 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// A collection of asynchronous observable sequences to concatenate. Cannot be null. extension(IEnumerable> sources) { - /// - /// Concatenates multiple asynchronous observable sequences into a single sequence that emits items from each source - /// in order. - /// + /// Concatenates multiple asynchronous observable sequences into a single sequence that emits items from each source in order. /// An asynchronous observable sequence that emits all items from each source sequence in the order they appear in /// the collection. /// A source is subscribed only after the previous one completes, so at most one source is active at a @@ -26,10 +23,7 @@ public static partial class SignalAsyncExtensions public IObservableAsync Chain() => new ChainEnumerableSignal(sources); - /// - /// Concatenates multiple asynchronous observable sequences into a single sequence that emits items from each source - /// in order. - /// + /// Concatenates multiple asynchronous observable sequences into a single sequence that emits items from each source in order. /// An asynchronous observable sequence that emits all items from each source sequence in order. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Concat() => diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs index f18e70cd..e6bb0b31 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Represents an asynchronous observable sequence that concatenates multiple asynchronous observables, emitting their -/// elements in order as each completes. -/// +/// Represents an asynchronous observable sequence that concatenates multiple asynchronous observables, emitting their elements in order as each completes. /// The type of elements produced by the concatenated observable sequences. /// A collection of asynchronous signals to be concatenated. Each signal is subscribed to sequentially; the next /// begins only after the previous completes. @@ -35,10 +32,7 @@ ValueTask IObservableAsync.SubscribeAsync( subscription.SubscribeNextSignalAsync); } - /// - /// Manages sequential iteration through the enumerable of observables, subscribing to each - /// inner observable only after the previous one completes. - /// + /// Manages sequential iteration through the enumerable of observables, subscribing to each inner observable only after the previous one completes. internal sealed class ChainSequenceCoordinator : IAsyncDisposable { /// Enumerator that iterates through the collection of observable sequences to concatenate. @@ -118,9 +112,7 @@ internal async ValueTask SubscribeNextSignalAsync() /// A task representing the asynchronous operation. internal ValueTask RelayInnerErrorAsync(Exception exception, CancellationToken cancellationToken) { - // The inner subscription is rooted in _disposedCancellationToken (see SubscribeNextSignalAsync), so - // disposing it cascades into the inner observer's own cancellation. Forwarding that token directly - // gives the same cancellation semantics as a linked CTS without allocating one per emission. + // The inner subscription shares this disposal token. _ = cancellationToken; return _observer.OnErrorResumeAsync(exception, _disposedCancellationToken); } @@ -135,10 +127,7 @@ internal ValueTask RelayInnerValueAsync(T value, CancellationToken cancellationT return _observer.OnNextAsync(value, _disposedCancellationToken); } - /// - /// Disposes the inner subscription and enumerator, and optionally forwards a completion result - /// to the downstream observer. This method is idempotent. - /// + /// Disposes the inner subscription and enumerator, and optionally forwards a completion result to the downstream observer. This method is idempotent. /// The completion result to forward, or if disposing without signaling completion. /// A task representing the asynchronous operation. internal async ValueTask FinishAsync(Result? result) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs index 292eeee3..cd5f496d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs @@ -8,10 +8,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Async observable that concatenates inner observable sequences emitted by an outer observable, -/// subscribing to each inner sequence only after the previous one completes. -/// +/// Async observable that concatenates inner observable sequences emitted by an outer observable, subscribing to each inner sequence only after the previous one completes. /// The type of elements produced by the inner observable sequences. /// The outer observable sequence that emits inner observable sequences to concatenate. public sealed class ChainSignalSourcesSignal(IObservableAsync> source) : IObservableAsync @@ -30,10 +27,7 @@ ValueTask IObservableAsync.SubscribeAsync( () => subscription.SubscribeAsync(source, cancellationToken)); } - /// - /// Manages the lifetime of the outer subscription and buffers inner observables, - /// subscribing to each one sequentially as the previous completes. - /// + /// Manages the lifetime of the outer subscription and buffers inner observables, subscribing to each one sequentially as the previous completes. internal sealed class ChainCoordinator : IAsyncDisposable { /// Concurrent queue that buffers inner observables waiting to be subscribed to. @@ -100,10 +94,7 @@ internal async ValueTask SubscribeAsync( await _outerDisposable.SetDisposableAsync(outerSubscription).ConfigureAwait(false); } - /// - /// Handles a new inner observable from the outer sequence by buffering it and subscribing - /// if no inner sequence is currently active. - /// + /// Handles a new inner observable from the outer sequence by buffering it and subscribing if no inner sequence is currently active. /// The inner observable to enqueue. /// A task representing the asynchronous operation. internal ValueTask AcceptOuterValueAsync(IObservableAsync inner) @@ -121,10 +112,7 @@ internal ValueTask AcceptOuterValueAsync(IObservableAsync inner) return !shouldSubscribe ? default : SubscribeCurrentInnerAsync(inner); } - /// - /// Handles the outer sequence completing, propagating completion downstream when the buffer is empty - /// or when the outer fails. - /// + /// Handles the outer sequence completing, propagating completion downstream when the buffer is empty or when the outer fails. /// The completion result from the outer sequence. /// A task representing the asynchronous completion operation. internal ValueTask AcceptOuterCompletionAsync(Result result) @@ -144,10 +132,7 @@ internal ValueTask AcceptOuterCompletionAsync(Result result) return shouldComplete ? FinishAsync(completeResult) : default; } - /// - /// Handles the current inner sequence completing, subscribing to the next buffered inner - /// sequence or completing the subscription if the outer has also completed. - /// + /// Handles the current inner sequence completing, subscribing to the next buffered inner sequence or completing the subscription if the outer has also completed. /// The completion result from the inner sequence. /// A task representing the asynchronous completion operation. internal ValueTask AcceptInnerCompletionAsync(Result result) @@ -192,10 +177,7 @@ await currentInner.SubscribeAsync(new ChainInnerWitness(this), _disposedCancella } } - /// - /// Disposes the inner and outer subscriptions and optionally forwards a completion result to - /// the downstream observer. This method is idempotent. - /// + /// Disposes the inner and outer subscriptions and optionally forwards a completion result to the downstream observer. This method is idempotent. /// The completion result to forward, or if disposing without signaling completion. /// A task representing the asynchronous operation. internal async ValueTask FinishAsync(Result? result) @@ -237,9 +219,7 @@ protected override async ValueTask OnErrorResumeAsyncCore( Exception error, CancellationToken cancellationToken) { - // The outer subscription is rooted in _disposedCancellationToken, so disposing it cascades - // into this observer's cancellation. Forwarding the dispose token directly gives the same - // cancellation semantics as a linked CTS without allocating one per emission. + // The outer subscription shares this disposal token. _ = cancellationToken; var token = subscription._disposedCancellationToken; using (await subscription._observerOnSomethingGate.EnterAsync(token).ConfigureAwait(false)) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs index a969cde6..80eec75d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/CompletionSignalDelegate.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Represents a method that subscribes to completion notifications and returns an asynchronous disposable used to -/// unsubscribe. -/// +/// Represents a method that subscribes to completion notifications and returns an asynchronous disposable used to unsubscribe. /// An action to be invoked with a when the completion event occurs. This callback is called to /// notify the subscriber of the completion result. /// An that unsubscribes the notification when disposed asynchronously. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs index 64784606..6a46fa14 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Distinct.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Returns a sequence that contains only distinct elements from the source sequence, using the default equality - /// comparer for the element type. - /// + /// Returns a sequence that contains only distinct elements from the source sequence, using the default equality comparer for the element type. /// An observable sequence that contains distinct elements from the source sequence. /// Only the first occurrence of each element reaches observers, in source order; the set of seen /// elements lives for the whole subscription. @@ -26,10 +23,7 @@ public IObservableAsync Distinct() return new DistinctSignal(source, EqualityComparer.Default); } - /// - /// Returns an observable sequence that contains only distinct elements from the source sequence, using the - /// specified equality comparer to determine uniqueness. - /// + /// Returns an observable sequence that contains only distinct elements from the source sequence, using the specified equality comparer to determine uniqueness. /// An equality comparer to compare values for equality. If null, the default equality comparer for the type is /// used. /// An observable sequence that emits each distinct element from the source sequence, in the order in which they @@ -44,10 +38,7 @@ public IObservableAsync Distinct(IEqualityComparer equalityComparer) return new DistinctSignal(source, equalityComparer); } - /// - /// Returns a sequence that contains distinct elements from the source sequence according to a specified key - /// selector function. - /// + /// Returns a sequence that contains distinct elements from the source sequence according to a specified key selector function. /// The type of the key returned by the key selector function. /// A function to extract the key for each element. Cannot be null. /// An observable sequence that contains only the first occurrence of each distinct key as determined by the key @@ -61,10 +52,7 @@ public IObservableAsync DistinctBy(Func keySelector) return new DistinctBySignal(source, keySelector, EqualityComparer.Default); } - /// - /// Returns an observable sequence that contains only distinct elements from the source sequence, comparing - /// values based on a specified key and equality comparer. - /// + /// Returns an observable sequence that contains only distinct elements from the source sequence, comparing values based on a specified key and equality comparer. /// The type of the key used to determine the distinctness of elements. /// A function to extract the key for each element. Cannot be null. /// An equality comparer to compare keys for equality. Cannot be null. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs index 177d980f..67adf464 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs @@ -30,10 +30,7 @@ public IObservableAsync Expire(TimeSpan dueTime) } } - /// - /// Async observable that mirrors the source but completes with a - /// once an inter-element gap exceeds the configured interval. - /// + /// Async observable that mirrors the source but completes with a once an inter-element gap exceeds the configured interval. /// The type of elements in the sequence. /// The source observable sequence. /// The maximum allowed inter-element interval. @@ -54,10 +51,7 @@ async ValueTask IObservableAsync.SubscribeAsync( return subscription; } - /// - /// Observer that resets a timer on each received element and signals a - /// if no element arrives within the configured interval. - /// + /// Observer that resets a timer on each received element and signals a if no element arrives within the configured interval. /// The downstream observer to forward elements to. /// The maximum allowed inter-element interval. /// The time provider used to schedule the deadline. @@ -90,13 +84,21 @@ internal void StartTimer(CancellationToken cancellationToken) } catch (Exception e) { - // A CreateTimer failure routes to the unhandled exception handler rather than tearing - // down the subscription: with no timer the operator degrades to a pass-through that - // forwards every emission and never signals a timeout. + // Timer creation failure is reported; values continue without timeout enforcement. UnhandledExceptionHandler.ReportUnhandledException(e); } } + /// Rearms the deadline unless disposal has removed the timer. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void RearmTimer() => + _timer?.Change(dueTime, System.Threading.Timeout.InfiniteTimeSpan); + + /// Stops the deadline unless disposal has removed the timer. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void StopTimer() => + _timer?.Change(System.Threading.Timeout.InfiniteTimeSpan, System.Threading.Timeout.InfiniteTimeSpan); + /// Rearms the deadline timer and forwards the element to the downstream observer. /// The element to forward. /// A token to cancel the operation. @@ -184,27 +186,10 @@ private void OnTimerFired() _ = FireTimeoutAsync(observer); } - - /// Rearms the timeout deadline for the next emission; _timer is null only when the source - /// emits after DisposeAsyncCore has torn the timer down, a race no deterministic test can hit. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void RearmTimer() => - _timer?.Change(dueTime, System.Threading.Timeout.InfiniteTimeSpan); - - /// Stops the timeout deadline on terminal forwarding; _timer is null only under the same - /// post-teardown race as . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void StopTimer() => - _timer?.Change(System.Threading.Timeout.InfiniteTimeSpan, System.Threading.Timeout.InfiniteTimeSpan); } } - /// - /// Async observable that mirrors the source but switches to a fallback observable - /// once an inter-element gap exceeds the configured interval. - /// + /// Async observable that mirrors the source but switches to a fallback observable once an inter-element gap exceeds the configured interval. /// The type of elements in the sequence. /// The source observable sequence. /// The maximum allowed inter-element interval. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs index 8e67ae98..81645b15 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/FirstOrDefaultAsync.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Asynchronously returns the first element that matches the specified predicate, or a default value if no such - /// element is found. - /// + /// Asynchronously returns the first element that matches the specified predicate, or a default value if no such element is found. /// A function to test each element for a condition. The method returns the first element for which this /// predicate returns . /// The value to return if no element satisfies the predicate. @@ -29,10 +26,7 @@ public static partial class SignalAsyncExtensions T? defaultValue) => source.FirstOrDefaultAsync(predicate, defaultValue, CancellationToken.None); - /// - /// Asynchronously returns the first element that matches the specified predicate, or a default value if no such - /// element is found. - /// + /// Asynchronously returns the first element that matches the specified predicate, or a default value if no such element is found. /// A function to test each element for a condition. The method returns the first element for which this /// predicate returns . /// The value to return if no element satisfies the predicate. @@ -66,10 +60,7 @@ public static partial class SignalAsyncExtensions public ValueTask FirstOrDefaultAsync(CancellationToken cancellationToken) => source.FirstOrDefaultAsync(default, cancellationToken); - /// - /// Asynchronously returns the first element of the sequence, or a specified default value if the sequence - /// contains no elements. - /// + /// Asynchronously returns the first element of the sequence, or a specified default value if the sequence contains no elements. /// The value to return if the sequence is empty. /// A task that represents the asynchronous operation. The task result contains the first element of the /// sequence, or if the sequence is empty. @@ -77,10 +68,7 @@ public static partial class SignalAsyncExtensions public ValueTask FirstOrDefaultAsync(T? defaultValue) => source.FirstOrDefaultAsync(defaultValue, CancellationToken.None); - /// - /// Asynchronously returns the first element of the sequence, or a specified default value if the sequence - /// contains no elements. - /// + /// Asynchronously returns the first element of the sequence, or a specified default value if the sequence contains no elements. /// The value to return if the sequence is empty. /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains the first element of the diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs index 338cacaa..58f2b100 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/FlatMap.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Projects each element of the observable sequence to an asynchronous observable sequence and - /// merges the resulting sequences into one observable sequence. - /// + /// Projects each element of the observable sequence to an asynchronous observable sequence and merges the resulting sequences into one observable sequence. /// The type of the elements in the projected inner sequences. /// A transform function to apply to each element; it returns an observable sequence /// for each element. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs index a445cb75..e6495cca 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides extension methods for asynchronous observable sequences, enabling functional operations such as scanning -/// and accumulation over streamed data. -/// +/// Provides extension methods for asynchronous observable sequences, enabling functional operations such as scanning and accumulation over streamed data. public static partial class SignalAsyncExtensions { /// Fold/Scan (running accumulation) operators for an observable source sequence. @@ -15,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Applies an accumulator function over the observable sequence and returns each intermediate result - /// using the specified asynchronous accumulator. - /// + /// Applies an accumulator function over the observable sequence and returns each intermediate result using the specified asynchronous accumulator. /// The type of the accumulated value. /// The initial accumulator value. /// An asynchronous accumulator function to be invoked on each element. Receives the current accumulator value, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs index a077a8a2..0b5a9dda 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/GroupBy.cs @@ -36,10 +36,7 @@ public IObservableAsync> GroupBy(Func AsyncSignalFactory.Create()); } - /// - /// Groups the elements of an asynchronous observable sequence according to a specified key selector function and - /// returns an observable sequence of grouped observables. - /// + /// Groups the elements of an asynchronous observable sequence according to a specified key selector function and returns an observable sequence of grouped observables. /// The type of the key returned by the key selector function. Must be non-null. /// A function to extract the key for each element in the source sequence. /// A function that provides a signal for each group, given its key. Used to control how elements are published diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs index 9f381fad..8c8a33c6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Creates a new observable sequence that contains only the elements from the source sequence that satisfy the - /// specified asynchronous predicate. - /// + /// Creates a new observable sequence that contains only the elements from the source sequence that satisfy the specified asynchronous predicate. /// A function that evaluates each element and its associated cancellation token, returning a ValueTask that /// resolves to to include the element in the resulting sequence; otherwise, . @@ -29,10 +26,7 @@ public static partial class SignalAsyncExtensions public IObservableAsync Keep(Func> predicate) => new KeepAsyncSignal(source, predicate); - /// - /// Creates a new observable sequence that contains only the elements from the current sequence that satisfy the - /// specified predicate. - /// + /// Creates a new observable sequence that contains only the elements from the current sequence that satisfy the specified predicate. /// A function to test each element for a condition. The element is included in the resulting sequence if the /// function returns . /// An observable sequence that contains elements from the current sequence that satisfy the specified @@ -56,20 +50,14 @@ public IObservableAsync KeepWith( return new KeepSyncSignal(source, value => predicate(state, value)); } - /// - /// Creates a new observable sequence that contains only the elements from the source sequence that satisfy the - /// specified asynchronous predicate. - /// + /// Creates a new observable sequence that contains only the elements from the source sequence that satisfy the specified asynchronous predicate. /// A function that evaluates each element and cancellation token. /// An observable sequence that emits only those elements for which the predicate returns . [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Where(Func> predicate) => new KeepAsyncSignal(source, predicate); - /// - /// Creates a new observable sequence that contains only the elements from the current sequence that satisfy the - /// specified predicate. - /// + /// Creates a new observable sequence that contains only the elements from the current sequence that satisfy the specified predicate. /// A function to test each element for a condition. /// An observable sequence that contains elements from the current sequence that satisfy the predicate. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs index 3fdaa43f..d1c248b7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/KeepType.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Projects each element of the observable sequence to the specified reference type and filters out elements - /// that are not of that type. - /// + /// Projects each element of the observable sequence to the specified reference type and filters out elements that are not of that type. /// The reference type to filter and project elements to. Must be a class. /// An observable sequence containing only the elements of type TResult from the original sequence. [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs index 5729144a..351fe1b3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/LastOrDefaultAsync.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Asynchronously returns the last element in the sequence that satisfies the specified predicate, or a default - /// value if no such element is found. - /// + /// Asynchronously returns the last element in the sequence that satisfies the specified predicate, or a default value if no such element is found. /// A function to test each element for a condition. The method returns the last element for which this /// predicate returns . /// The value to return if no element in the sequence satisfies the predicate. @@ -29,10 +26,7 @@ public static partial class SignalAsyncExtensions T? defaultValue) => source.LastOrDefaultAsync(predicate, defaultValue, CancellationToken.None); - /// - /// Asynchronously returns the last element in the sequence that satisfies the specified predicate, or a default - /// value if no such element is found. - /// + /// Asynchronously returns the last element in the sequence that satisfies the specified predicate, or a default value if no such element is found. /// A function to test each element for a condition. The method returns the last element for which this /// predicate returns . /// The value to return if no element in the sequence satisfies the predicate. @@ -66,10 +60,7 @@ public static partial class SignalAsyncExtensions public ValueTask LastOrDefaultAsync(CancellationToken cancellationToken) => source.LastOrDefaultAsync(default, cancellationToken); - /// - /// Asynchronously returns the last element of the sequence, or a specified default value if the sequence - /// contains no elements. - /// + /// Asynchronously returns the last element of the sequence, or a specified default value if the sequence contains no elements. /// The value to return if the sequence is empty. /// A value task that represents the asynchronous operation. The task result contains the last element of the /// sequence, or if the sequence is empty. @@ -77,10 +68,7 @@ public static partial class SignalAsyncExtensions public ValueTask LastOrDefaultAsync(T? defaultValue) => source.LastOrDefaultAsync(defaultValue, CancellationToken.None); - /// - /// Asynchronously returns the last element of the sequence, or a specified default value if the sequence - /// contains no elements. - /// + /// Asynchronously returns the last element of the sequence, or a specified default value if the sequence contains no elements. /// The value to return if the sequence is empty. /// A cancellation token that can be used to cancel the asynchronous operation. /// A value task that represents the asynchronous operation. The task result contains the last element of the diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs index db561212..23c10ef2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Lead.cs @@ -14,19 +14,13 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Returns a new observable sequence that begins with the specified value, followed by the elements of the - /// current sequence. - /// + /// Returns a new observable sequence that begins with the specified value, followed by the elements of the current sequence. /// The value to prepend to the beginning of the sequence. /// An observable sequence with the specified value prepended to the original sequence. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Lead(T value) => new LeadSignal(source, [value]); - /// - /// Returns a new observable sequence that begins with the specified value, followed by the elements of the - /// current sequence. - /// + /// Returns a new observable sequence that begins with the specified value, followed by the elements of the current sequence. /// The value to prepend to the beginning of the sequence. /// An observable sequence with the specified value prepended to the original sequence. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs index cf132ebd..f71f81a4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides a set of extension methods for creating and managing connectable asynchronous observables using various -/// Signal types. -/// +/// Provides a set of extension methods for creating and managing connectable asynchronous observables using various Signal types. public static partial class SignalAsyncExtensions { /// Multicasting and publishing operators for an observable source sequence. @@ -17,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source sequence. extension(IObservableAsync source) { - /// - /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence using - /// the specified Signal. - /// + /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence using the specified Signal. /// The signal that multicasts the source elements to multiple observers. Cannot be null. /// A connectable observable sequence that multicasts the source sequence through the specified signal. /// The source is not subscribed until Connect is called, so observers can subscribe first. @@ -37,10 +31,7 @@ public ConnectableSignalAsync Multicast(ISignalAsync signal) => public ConnectableSignalAsync Publish() => new(source, new SerialSignalAsync()); - /// - /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence, - /// using a Signal created with the specified options. - /// + /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence, using a Signal created with the specified options. /// The options that configure the multicasting signal. Cannot be null. /// A connectable observable sequence that multicasts the source sequence using a Signal configured with the /// specified options. @@ -72,10 +63,7 @@ public ConnectableSignalAsync Publish(SignalCreationOptions options) => public ConnectableSignalAsync Publish(T initialValue) => new(source, new SerialReplayLatestSignalAsync(new(initialValue))); - /// - /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence and - /// starts with the specified initial value. - /// + /// Creates a connectable observable sequence that shares a single subscription to the underlying sequence and starts with the specified initial value. /// The initial value to be emitted to subscribers before any items are emitted by the source sequence. /// The options that configure the underlying behavior signal. /// A connectable observable sequence that multicasts the source sequence and emits the specified initial value @@ -100,10 +88,7 @@ public ConnectableSignalAsync Publish(T initialValue, BehaviorSignalCreationO "Unsupported behavior signal creation options.") }); - /// - /// Creates a connectable observable sequence that shares a single subscription to the underlying source and - /// does not retain any state between subscriptions. - /// + /// Creates a connectable observable sequence that shares a single subscription to the underlying source and does not retain any state between subscriptions. /// A connectable observable sequence that multicasts notifications from the source without retaining state /// between subscribers. /// Each connection is independent of the ones around it, and no value is cached for late subscribers. @@ -129,10 +114,7 @@ public ConnectableSignalAsync StatelessPublish(T initialValue) => public ConnectableSignalAsync ReplayLatestPublish() => new(source, new SerialReplayLatestSignalAsync(Optional.Empty)); - /// - /// Creates a connectable observable sequence that replays only the latest published value to new subscribers, - /// using the specified replay Signal creation options. - /// + /// Creates a connectable observable sequence that replays only the latest published value to new subscribers, using the specified replay Signal creation options. /// The options that configure the replay signal. /// A connectable observable sequence that replays the most recent value to each new subscriber after /// connection. @@ -156,10 +138,7 @@ public ConnectableSignalAsync ReplayLatestPublish(ReplayLatestSignalCreationO "Unsupported replay-latest signal creation options.") }); - /// - /// Creates a connectable observable sequence that replays only the latest item to new subscribers and publishes - /// items to all current subscribers. - /// + /// Creates a connectable observable sequence that replays only the latest item to new subscribers and publishes items to all current subscribers. /// A connectable observable sequence that replays the most recent item to new subscribers and multicasts /// notifications to all current subscribers. /// Each connection replays only the most recent value published during that connection; nothing is diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs index 1c1dea53..9e7d4889 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs @@ -147,12 +147,11 @@ private async ValueTask AwaitAndForwardAsync( } } - /// Drops upstream duplicates, debounces each value, and suppresses duplicates again before forwarding. + /// Schedules the value without canceling superseded delays; only the current value is emitted. /// The element type. /// The upstream observable. /// The debounce window. /// The time provider for the debounce timer. - /// A superseded delay is not cancelled: it runs to completion and its result is dropped by the id check. internal sealed class ThrottleDistinctSignal( IObservableAsync source, TimeSpan dueTime, @@ -232,15 +231,18 @@ internal bool TryClaimEmission(T value, long id) } } - /// - protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) + /// Starts a delay for a distinct upstream value and supersedes the previous pending value. + /// The upstream value. + /// Cancellation for the delay. + /// The pending emission, or a completed task for a duplicate value. + internal Task StartDelayAsync(T value, CancellationToken cancellationToken) { long currentId; lock (_gate) { if (_hasUpstream && Comparer.Equals(value, _lastUpstream)) { - return default; + return Task.CompletedTask; } _lastUpstream = value; @@ -248,7 +250,13 @@ protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancella currentId = ++_id; } - _ = FireAfterDelayAsync(value, currentId, cancellationToken); + return FireAfterDelayAsync(value, currentId, cancellationToken); + } + + /// + protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) + { + _ = StartDelayAsync(value, cancellationToken); return default; } @@ -426,14 +434,12 @@ private async ValueTask AwaitFullAsync(ValueTask actionTask, T value, Cancellati } } - /// Forwards a value at once when the condition holds, otherwise after the debounce window elapses. + /// Schedules the latest value, discarding superseded delay results. /// The element type. /// The upstream observable. /// The debounce window applied to bypass-false values. /// When the value bypasses the delay and is forwarded immediately. /// The time provider for the debounce timer. - /// Each new upstream value supersedes a pending delay, which is not cancelled: it runs to completion and its - /// result is dropped by the id check. internal sealed class DebounceUntilSignal( IObservableAsync source, TimeSpan debounce, @@ -488,6 +494,21 @@ internal bool IsCurrentEmission(long id) } } + /// Starts a delay that supersedes the previously pending value. + /// The pending value. + /// Cancellation for the delay. + /// The delay and any downstream notification. + internal Task StartDelayAsync(T value, CancellationToken cancellationToken) + { + long currentId; + lock (_gate) + { + currentId = ++_id; + } + + return DelayAndEmitAsync(value, currentId, cancellationToken); + } + /// protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) { @@ -502,13 +523,7 @@ protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancella return downstream.OnNextAsync(value, cancellationToken); } - long currentId; - lock (_gate) - { - currentId = ++_id; - } - - _ = DelayAndEmitAsync(value, currentId, cancellationToken); + _ = StartDelayAsync(value, cancellationToken); return default; } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs index e6db6101..dfcfe537 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs @@ -2,7 +2,6 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Async.Disposables; @@ -11,11 +10,7 @@ namespace ReactiveUI.Primitives.Async; /// Provides the shared upstream coordinator backing the Partition parity helper. public static partial class SignalAsyncExtensions { - /// - /// Shares one upstream subscription between the two Partition branches, starting it when the first branch - /// subscribes and tearing it down when the last one disposes. Each emission dispatches to whichever branch observer - /// the predicate selects, or to nothing when that branch has no subscriber. - /// + /// Shares an upstream subscription between predicate-selected branches until the last subscriber disposes. /// The element type partitioned across the two branches. internal sealed class PartitionCoordinator { @@ -126,8 +121,7 @@ internal bool TryAttachSourceSubscription(IAsyncDisposable subscription) /// Attaches the new upstream subscription, disposing it when both branches disposed during the subscribe. /// The freshly-created upstream subscription. /// A task that completes once the subscription has been attached or disposed. - [ExcludeFromCodeCoverage] - private async ValueTask AttachOrDisposeStaleSubscriptionAsync(IAsyncDisposable subscription) + internal async ValueTask AttachOrDisposeStaleSubscriptionAsync(IAsyncDisposable subscription) { if (!TryAttachSourceSubscription(subscription)) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs index 3ca19c7c..ad0c0a3b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs @@ -226,10 +226,7 @@ public IObservableAsync WaitUntil(Func predicate) return new PairwiseSignal(source); } - /// - /// Partitions the source sequence into values that satisfy the predicate and values that do not. - /// The predicate is evaluated exactly once per element. - /// + /// Partitions the source sequence into values that satisfy the predicate and values that do not. The predicate is evaluated exactly once per element. /// The partition predicate. /// A tuple of true and false partitions. public (IObservableAsync True, IObservableAsync False) Partition(Func predicate) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs index dcd5f9fe..29815e2d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Creates a new observable sequence that continues with a handler-provided sequence when an exception occurs - /// in the source sequence. - /// + /// Creates a new observable sequence that continues with a handler-provided sequence when an exception occurs in the source sequence. /// A function that receives the exception thrown by the source sequence and returns an alternative observable /// sequence to continue with. /// An observable sequence that emits items from the source sequence, or from the handler-provided sequence if @@ -42,10 +39,7 @@ public IObservableAsync Resume(IObservableAsync fallback) return new CatchSignal(source, _ => fallback, null); } - /// - /// Continues the observable sequence with an alternative sequence provided by the specified handler when an - /// error occurs, and ignores the error after invoking the handler. - /// + /// Continues the observable sequence with an alternative sequence provided by the specified handler when an error occurs, and ignores the error after invoking the handler. /// A function that receives the exception and returns an alternative observable sequence to resume with after /// an error occurs. /// An observable sequence that resumes with the sequence returned by the handler when an error is encountered, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs index 3e5722b8..8cdfe496 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Applies an asynchronous accumulator function over the observable sequence, returning the - /// final accumulated value when the sequence completes. - /// + /// Applies an asynchronous accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An asynchronous accumulator function to invoke on each element. Receives the @@ -30,10 +27,7 @@ public ValueTask AggregateAsync( Func> accumulator) => source.ReduceAsync(seed, accumulator); - /// - /// Applies an asynchronous accumulator function over the observable sequence, returning the - /// final accumulated value when the sequence completes. - /// + /// Applies an asynchronous accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An asynchronous accumulator function to invoke on each element. Receives the @@ -48,10 +42,7 @@ public ValueTask AggregateAsync( CancellationToken cancellationToken) => source.ReduceAsync(seed, accumulator, cancellationToken); - /// - /// Applies an accumulator function over the observable sequence, returning the final accumulated - /// value when the sequence completes. - /// + /// Applies an accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An accumulator function to invoke on each element. Receives the current @@ -64,10 +55,7 @@ public ValueTask AggregateAsync( Func accumulator) => source.ReduceAsync(seed, accumulator); - /// - /// Applies an accumulator function over the observable sequence, returning the final accumulated - /// value when the sequence completes. - /// + /// Applies an accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An accumulator function to invoke on each element. Receives the current @@ -82,10 +70,7 @@ public ValueTask AggregateAsync( CancellationToken cancellationToken) => source.ReduceAsync(seed, accumulator, cancellationToken); - /// - /// Applies an accumulator function over the observable sequence with a seed value, then applies - /// a result selector to the final accumulated value. - /// + /// Applies an accumulator function over the observable sequence with a seed value, then applies a result selector to the final accumulated value. /// The type of the intermediate accumulated value. /// The type of the result value. /// The initial accumulator value. @@ -101,10 +86,7 @@ public ValueTask AggregateAsync( Func resultSelector) => source.ReduceAsync(seed, accumulator, resultSelector); - /// - /// Applies an accumulator function over the observable sequence with a seed value, then applies - /// a result selector to the final accumulated value. - /// + /// Applies an accumulator function over the observable sequence with a seed value, then applies a result selector to the final accumulated value. /// The type of the intermediate accumulated value. /// The type of the result value. /// The initial accumulator value. @@ -122,10 +104,7 @@ public ValueTask AggregateAsync( CancellationToken cancellationToken) => source.ReduceAsync(seed, accumulator, resultSelector, cancellationToken); - /// - /// Applies an asynchronous accumulator function over the observable sequence, returning the - /// final accumulated value when the sequence completes. - /// + /// Applies an asynchronous accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An asynchronous accumulator function to invoke on each element. Receives the @@ -138,10 +117,7 @@ public ValueTask ReduceAsync( Func> accumulator) => source.ReduceAsync(seed, accumulator, CancellationToken.None); - /// - /// Applies an asynchronous accumulator function over the observable sequence, returning the - /// final accumulated value when the sequence completes. - /// + /// Applies an asynchronous accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An asynchronous accumulator function to invoke on each element. Receives the @@ -163,10 +139,7 @@ public async ValueTask ReduceAsync( return await observer.AwaitResultAsync().ConfigureAwait(false); } - /// - /// Applies an accumulator function over the observable sequence, returning the final accumulated - /// value when the sequence completes. - /// + /// Applies an accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An accumulator function to invoke on each element. Receives the current @@ -179,10 +152,7 @@ public ValueTask ReduceAsync( Func accumulator) => source.ReduceAsync(seed, accumulator, CancellationToken.None); - /// - /// Applies an accumulator function over the observable sequence, returning the final accumulated - /// value when the sequence completes. - /// + /// Applies an accumulator function over the observable sequence, returning the final accumulated value when the sequence completes. /// The type of the accumulated value. /// The initial accumulator value. /// An accumulator function to invoke on each element. Receives the current @@ -204,10 +174,7 @@ public async ValueTask ReduceAsync( return await observer.AwaitResultAsync().ConfigureAwait(false); } - /// - /// Applies an accumulator function over the observable sequence with a seed value, then applies - /// a result selector to the final accumulated value. - /// + /// Applies an accumulator function over the observable sequence with a seed value, then applies a result selector to the final accumulated value. /// The type of the intermediate accumulated value. /// The type of the result value. /// The initial accumulator value. @@ -223,10 +190,7 @@ public ValueTask ReduceAsync( Func resultSelector) => source.ReduceAsync(seed, accumulator, resultSelector, CancellationToken.None); - /// - /// Applies an accumulator function over the observable sequence with a seed value, then applies - /// a result selector to the final accumulated value. - /// + /// Applies an accumulator function over the observable sequence with a seed value, then applies a result selector to the final accumulated value. /// The type of the intermediate accumulated value. /// The type of the result value. /// The initial accumulator value. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs index 59ee2333..c1660ae7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs @@ -27,10 +27,7 @@ public IObservableAsync RefCount() => new RefCountSignal(source); } - /// - /// Async observable that automatically connects to the underlying connectable source when the first - /// observer subscribes and disconnects when the last observer unsubscribes. - /// + /// Async observable that automatically connects to the underlying connectable source when the first observer subscribes and disconnects when the last observer unsubscribes. /// The type of elements in the sequence. /// The connectable observable to manage with reference counting. internal sealed class RefCountSignal(ConnectableSignalAsync source) : IObservableAsync, IDisposable @@ -73,10 +70,7 @@ internal void Dispose(bool disposing) _connection?.DisposeAsync().AsTask().GetAwaiter().GetResult(); } - /// - /// Subscribes the specified observer, incrementing the reference count and connecting to the source - /// if this is the first subscriber. - /// + /// Subscribes the specified observer, incrementing the reference count and connecting to the source if this is the first subscriber. /// The observer to receive elements from the connectable source. /// A token to cancel the subscription. /// An async disposable that decrements the reference count on disposal. @@ -107,10 +101,7 @@ await connection } } - /// - /// Witness wrapper that forwards all notifications and decrements the parent's reference count on disposal, - /// disconnecting from the source when the count reaches zero. - /// + /// Witness wrapper that forwards all notifications and decrements the parent's reference count on disposal, disconnecting from the source when the count reaches zero. /// The parent ref-count observable. /// The downstream witness to forward notifications to. internal sealed class RefCountWitness(RefCountSignal parent, IObserverAsync observer) : WitnessAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs index 9a9bfa0b..62c90480 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Time-shifts the observable sequence by the specified time span. Each element notification - /// is delayed by the specified duration. - /// + /// Time-shifts the observable sequence by the specified time span. Each element notification is delayed by the specified duration. /// The time span by which to delay each element notification. Must be non-negative. /// An observable sequence with element notifications time-shifted by the specified duration. /// Thrown if is negative. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs index 5420d95b..673a7300 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Catch.Rx.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Creates a new observable sequence that continues with a handler-provided sequence when an exception occurs - /// in the source sequence. - /// + /// Creates a new observable sequence that continues with a handler-provided sequence when an exception occurs in the source sequence. /// A function that receives the exception thrown by the source sequence and returns an alternative observable /// sequence to continue with. /// An observable sequence that emits items from the source sequence, or from the handler-provided sequence if @@ -29,10 +26,7 @@ public IObservableAsync Catch(Func> handler) return new CatchSignal(source, handler, null); } - /// - /// Creates a new observable sequence that continues with a handler-provided sequence when an exception occurs - /// in the source sequence. - /// + /// Creates a new observable sequence that continues with a handler-provided sequence when an exception occurs in the source sequence. /// A function that receives the exception thrown by the source sequence and returns an alternative observable /// sequence to continue with. /// An optional asynchronous callback invoked when an error occurs. If not specified, the observer's default diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.CombineLatest.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.CombineLatest.Rx.cs index 9d577ed2..274164ed 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.CombineLatest.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.CombineLatest.Rx.cs @@ -23,10 +23,7 @@ public IObservableAsync> CombineLatest() return new SyncLatestEnumerableSignal>(sources, static values => values); } - /// - /// Combines the latest value from each asynchronous observable sequence in the supplied collection and projects the - /// resulting snapshot into a result value. - /// + /// Combines the latest value from each asynchronous observable sequence in the supplied collection and projects the resulting snapshot into a result value. /// The projected result type. /// A selector that projects the current snapshot of latest values into a result value. /// An observable sequence that emits projected results whenever any source produces a new value, after all diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs index 307550e0..b29b3292 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Time-shifts the observable sequence by the specified time span. Each element notification - /// is delayed by the specified duration. - /// + /// Time-shifts the observable sequence by the specified time span. Each element notification is delayed by the specified duration. /// The time span by which to delay each element notification. Must be non-negative. /// An observable sequence with element notifications time-shifted by the specified duration. /// Thrown if is negative. @@ -28,10 +25,7 @@ public IObservableAsync Delay(TimeSpan delayInterval) : new DelaySignal(source, delayInterval, TimeProvider.System); } - /// - /// Time-shifts the observable sequence by the specified time span. Each element notification - /// is delayed by the specified duration. - /// + /// Time-shifts the observable sequence by the specified time span. Each element notification is delayed by the specified duration. /// The time span by which to delay each element notification. Must be non-negative. /// An optional time provider for controlling timing. If null, /// is used. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs index b504de43..5060d051 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Returns an observable sequence that emits only distinct consecutive elements, suppressing duplicates that - /// are equal to the previous element. - /// + /// Returns an observable sequence that emits only distinct consecutive elements, suppressing duplicates that are equal to the previous element. /// An observable sequence that contains only the elements from the source sequence that are not equal to their /// immediate predecessor. public IObservableAsync DistinctUntilChanged() @@ -42,10 +39,7 @@ public IObservableAsync DistinctUntilChanged(IEqualityComparer equalityCom return new UniqueSignal(source, equalityComparer); } - /// - /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive - /// duplicates as determined by a key selector function. - /// + /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive duplicates as determined by a key selector function. /// The type of the key that decides whether consecutive elements are duplicates. /// A function that extracts the comparison key from each element in the source sequence. /// An observable sequence that contains only the elements from the source sequence that are not consecutive @@ -59,10 +53,7 @@ public IObservableAsync DistinctUntilChangedBy(Func keySelecto return new UniqueBySignal(source, keySelector, equalityComparer); } - /// - /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive - /// duplicates as determined by a key selector and equality comparer. - /// + /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive duplicates as determined by a key selector and equality comparer. /// The type of the key that decides whether consecutive elements are duplicates. /// A function that extracts the comparison key from each element in the source sequence. /// The comparer that compares keys for equality. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs index e84c56b9..88f35d07 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Applies an accumulator function over the observable sequence and returns each intermediate result - /// using the specified asynchronous accumulator. - /// + /// Applies an accumulator function over the observable sequence and returns each intermediate result using the specified asynchronous accumulator. /// The type of the accumulated value. /// The initial accumulator value. /// An asynchronous accumulator function to be invoked on each element. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs index 72ffa99f..9b1fc010 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Projects each element of the observable sequence to an asynchronous observable sequence and - /// merges the resulting sequences into one observable sequence. - /// + /// Projects each element of the observable sequence to an asynchronous observable sequence and merges the resulting sequences into one observable sequence. /// The type of the elements in the projected inner sequences. /// A transform function to apply to each element. /// An observable sequence whose elements are the merged projection results. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs index ff682fdb..4eed6c62 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides extension methods for asynchronous observable sequences, enabling operations such as retrieving a single -/// element that matches a specified condition. -/// +/// Provides extension methods for asynchronous observable sequences, enabling operations such as retrieving a single element that matches a specified condition. public static partial class SignalAsyncExtensions { /// Single-element operators for an observable source sequence. @@ -17,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Asynchronously returns the single element of a sequence that satisfies a specified condition, or throws an - /// exception if more than one such element exists. - /// + /// Asynchronously returns the single element of a sequence that satisfies a specified condition, or throws an exception if more than one such element exists. /// A function to test each element for a condition. The method returns the element for which this predicate /// returns . /// A task that represents the asynchronous operation. The task result contains the single element that matches @@ -30,10 +24,7 @@ public static partial class SignalAsyncExtensions public ValueTask SingleAsync(Func predicate) => source.SingleAsync(predicate, CancellationToken.None); - /// - /// Asynchronously returns the single element of a sequence that satisfies a specified condition, or throws an - /// exception if more than one such element exists. - /// + /// Asynchronously returns the single element of a sequence that satisfies a specified condition, or throws an exception if more than one such element exists. /// A function to test each element for a condition. The method returns the element for which this predicate /// returns . /// The token that cancels the operation. @@ -44,10 +35,7 @@ public ValueTask SingleAsync(Func predicate) => public ValueTask SingleAsync(Func predicate, CancellationToken cancellationToken) => SingleCoreAsync(source, predicate, cancellationToken); - /// - /// Asynchronously returns the single element of the sequence, and throws an exception if the sequence does not - /// contain exactly one element. - /// + /// Asynchronously returns the single element of the sequence, and throws an exception if the sequence does not contain exactly one element. /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence. /// Both an empty sequence and a sequence of more than one element throw. @@ -55,10 +43,7 @@ public ValueTask SingleAsync(Func predicate, CancellationToken cance public ValueTask SingleAsync() => source.SingleAsync(CancellationToken.None); - /// - /// Asynchronously returns the single element of the sequence, and throws an exception if the sequence does not - /// contain exactly one element. - /// + /// Asynchronously returns the single element of the sequence, and throws an exception if the sequence does not contain exactly one element. /// The token that cancels the operation. /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs index d4366b6f..d6bf893a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs @@ -50,20 +50,14 @@ public static partial class SignalAsyncExtensions CancellationToken cancellationToken) => SingleOrDefaultCoreAsync(source, predicate, defaultValue, cancellationToken); - /// - /// Asynchronously returns the only element of a sequence, or a default value if the sequence is empty; this - /// operation throws an exception if more than one element is found. - /// + /// Asynchronously returns the only element of a sequence, or a default value if the sequence is empty; this operation throws an exception if more than one element is found. /// A value task that represents the asynchronous operation. The task result contains the single element of the /// sequence, or the default value of if the sequence is empty. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync() => source.SingleOrDefaultAsync(default, CancellationToken.None); - /// - /// Asynchronously returns the only element of a sequence, or a default value if the sequence is empty; this - /// operation throws an exception if more than one element is found. - /// + /// Asynchronously returns the only element of a sequence, or a default value if the sequence is empty; this operation throws an exception if more than one element is found. /// The token that cancels the operation. /// A value task that represents the asynchronous operation. The task result contains the single element of the /// sequence, or the default value of if the sequence is empty. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs index 506134ce..81f99d86 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Bypasses elements in the observable sequence as long as the specified asynchronous condition is true, - /// then emits all remaining elements. - /// + /// Bypasses elements in the observable sequence as long as the specified asynchronous condition is true, then emits all remaining elements. /// An asynchronous function to test each element for a condition. Receives the element /// and a cancellation token. /// An observable sequence that skips elements while the predicate returns true and emits @@ -29,10 +26,7 @@ public IObservableAsync SkipWhile(Func> return new SkipWhileAsyncSignal(source, predicate); } - /// - /// Bypasses elements in the observable sequence as long as the specified condition is true, - /// then emits all remaining elements. - /// + /// Bypasses elements in the observable sequence as long as the specified condition is true, then emits all remaining elements. /// A function to test each element for a condition. /// An observable sequence that skips elements while the predicate returns true and emits /// all subsequent elements. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs index 4596fa62..b49f7a58 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Subscribes to the asynchronous data source and invokes the specified callbacks for each item, error, or - /// completion notification. - /// + /// Subscribes to the asynchronous data source and invokes the specified callbacks for each item, error, or completion notification. /// A delegate that is invoked asynchronously for each item received from the data source. The delegate receives /// the item and a cancellation token. /// An optional delegate that is invoked asynchronously if an error occurs during data processing. The delegate @@ -54,10 +51,7 @@ public ValueTask SubscribeAsync( Func? onErrorResumeAsync) => source.SubscribeAsync(onNextAsync, onErrorResumeAsync, null, CancellationToken.None); - /// - /// Subscribes to the asynchronous data source and invokes the specified callbacks for each item, error, or - /// completion notification. - /// + /// Subscribes to the asynchronous data source and invokes the specified callbacks for each item, error, or completion notification. /// A delegate that is invoked asynchronously for each item received from the data source. /// An optional delegate that is invoked asynchronously if an error occurs during data processing. /// An optional delegate that is invoked asynchronously when the data source completes successfully. @@ -101,10 +95,7 @@ public ValueTask SubscribeAsync( public ValueTask SubscribeAsync(Action onNext) => source.SubscribeAsync(onNext, CancellationToken.None); - /// - /// Subscribes to the observable sequence asynchronously, invoking the specified callbacks for each element, - /// error, or completion notification. - /// + /// Subscribes to the observable sequence asynchronously, invoking the specified callbacks for each element, error, or completion notification. /// An action to invoke for each element in the sequence. Cannot be null. /// An optional action to invoke if an error occurs during the sequence. If null, errors are not handled by the /// subscriber. @@ -163,10 +154,7 @@ public ValueTask SubscribeAsync() => public ValueTask SubscribeAsync(Func onNextAsync) => source.SubscribeAsync(onNextAsync, CancellationToken.None); - /// - /// Subscribes asynchronously to receive notifications for each item in the sequence using the specified - /// asynchronous callback. - /// + /// Subscribes asynchronously to receive notifications for each item in the sequence using the specified asynchronous callback. /// A function to invoke asynchronously for each item in the sequence. The function receives the item and a /// cancellation token, and returns a ValueTask that completes when processing is finished. /// The token that cancels the subscription. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs index d20c1afa..531b0ff4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchTo.cs @@ -14,19 +14,13 @@ public static partial class SignalAsyncExtensions /// The source observable sequence of observable sequences. extension(IObservableAsync> source) { - /// - /// Transforms an observable sequence of observable sequences into a single observable sequence that emits - /// values from the most recent inner observable sequence. - /// + /// Transforms an observable sequence of observable sequences into a single observable sequence that emits values from the most recent inner observable sequence. /// An observable sequence that emits items from the most recently emitted inner observable sequence. When a new /// inner sequence is emitted, the previous one is unsubscribed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync SwitchTo() => new SwitchToSignal(source); - /// - /// Transforms an observable sequence of observable sequences into a single observable sequence that emits - /// values from the most recent inner observable sequence. - /// + /// Transforms an observable sequence of observable sequences into a single observable sequence that emits values from the most recent inner observable sequence. /// An observable sequence that emits items from the most recently emitted inner observable sequence. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Switch() => new SwitchToSignal(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs index 1e6ea198..817c72bd 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Async observable that switches to the most recently emitted inner observable sequence, -/// unsubscribing from the previous inner sequence each time a new one arrives. -/// +/// Async observable that switches to the most recently emitted inner observable sequence, unsubscribing from the previous inner sequence each time a new one arrives. /// The type of elements produced by the inner observable sequences. /// The outer observable sequence that emits inner observable sequences. public sealed class SwitchToSignal(IObservableAsync> source) : IObservableAsync @@ -30,10 +27,7 @@ ValueTask IObservableAsync.SubscribeAsync( () => subscription.SubscribeAsync(source, cancellationToken)); } - /// - /// Manages the lifetime of the outer subscription and the currently active inner subscription, - /// switching to new inner sequences as they arrive. - /// + /// Manages the lifetime of the outer subscription and the currently active inner subscription, switching to new inner sequences as they arrive. internal sealed class SwitchToCoordinator : IAsyncDisposable { /// The downstream observer to forward elements to. @@ -91,10 +85,7 @@ internal async ValueTask SubscribeAsync( await _outerDisposable.SetDisposableAsync(outerSubscription).ConfigureAwait(false); } - /// - /// Handles a new inner observable from the outer sequence by disposing the previous inner subscription - /// and subscribing to the new one. - /// + /// Handles a new inner observable from the outer sequence by disposing the previous inner subscription and subscribing to the new one. /// The new inner observable to switch to. /// A task representing the asynchronous switch operation. internal ValueTask AcceptOuterValueAsync(IObservableAsync inner) @@ -109,10 +100,7 @@ internal ValueTask AcceptOuterValueAsync(IObservableAsync inner) return SubscribeReplacementInnerAsync(inner, previousSubscription); } - /// - /// Handles the outer sequence completing, propagating completion downstream when no inner - /// sequence is active or when the outer fails. - /// + /// Handles the outer sequence completing, propagating completion downstream when no inner sequence is active or when the outer fails. /// The completion result from the outer sequence. /// A task representing the asynchronous completion operation. internal ValueTask AcceptOuterCompletionAsync(Result result) @@ -132,10 +120,7 @@ internal ValueTask AcceptOuterCompletionAsync(Result result) return shouldComplete ? FinishAsync(Result.Success) : default; } - /// - /// Handles the current inner sequence completing, propagating completion downstream - /// if the outer has also completed, or waiting for the next inner sequence otherwise. - /// + /// Handles the current inner sequence completing, propagating completion downstream if the outer has also completed, or waiting for the next inner sequence otherwise. /// The completion result from the inner sequence. /// A task representing the asynchronous completion operation. internal ValueTask AcceptInnerCompletionAsync(Result result) @@ -253,10 +238,7 @@ internal async ValueTask SubscribeReplacementInnerAsync( } } - /// - /// Disposes the current inner subscription, the outer subscription, and optionally forwards a - /// completion result to the downstream observer. This method is idempotent. - /// + /// Disposes the current inner subscription, the outer subscription, and optionally forwards a completion result to the downstream observer. This method is idempotent. /// The completion result to forward, or if disposing without signaling completion. /// A task representing the asynchronous operation. internal async ValueTask FinishAsync(Result? result) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs index e9571374..16fd4031 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest10.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 10 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 10 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -60,10 +57,7 @@ public IObservableAsync SyncLatest - /// Combines the latest values from 10 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 10 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs index 082d77a9..2d85db8a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest11.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 11 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 11 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -63,10 +60,7 @@ public IObservableAsync SyncLatest - /// Combines the latest values from 11 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 11 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs index 43bfb58a..e179daf8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest12.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 12 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 12 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -66,10 +63,7 @@ public IObservableAsync SyncLatest - /// Combines the latest values from 12 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 12 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs index 43be92df..ec10f526 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest13.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 13 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 13 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -69,10 +66,7 @@ public IObservableAsync SyncLatest - /// Combines the latest values from 13 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 13 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs index eca356c5..3bbde563 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest14.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 14 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 14 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -72,10 +69,7 @@ public IObservableAsync SyncLatest - /// Combines the latest values from 14 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 14 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs index 2a52c9ff..98ddf7fe 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest15.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 15 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 15 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -90,10 +87,7 @@ public IObservableAsync SyncLatest< new(src1, src2, src3, src4, src5, src6, src7, src8, src9, src10, src11, src12, src13, src14, src15), selector); - /// - /// Combines the latest values from 15 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 15 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs index 37bef000..64ae8659 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest16.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 16 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 16 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -94,10 +91,7 @@ public IObservableAsync SyncLatest - /// Combines the latest values from 16 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 16 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest3.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest3.cs index 0ab27988..8fdc8a30 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest3.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest3.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 3 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 3 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The projected element type. @@ -34,10 +31,7 @@ public IObservableAsync SyncLatest( new(src1, src2, src3), selector); - /// - /// Combines the latest values from 3 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 3 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The projected element type. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest4.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest4.cs index f4688e57..eb2f170d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest4.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest4.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 4 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 4 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -37,10 +34,7 @@ public IObservableAsync SyncLatest( new(src1, src2, src3, src4), selector); - /// - /// Combines the latest values from 4 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 4 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest5.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest5.cs index 76c13da5..b41a2165 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest5.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest5.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 5 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 5 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -40,10 +37,7 @@ public IObservableAsync SyncLatest( new(src1, src2, src3, src4, src5), selector); - /// - /// Combines the latest values from 5 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 5 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest6.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest6.cs index 09359d2c..9554ef42 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest6.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest6.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 6 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 6 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -43,10 +40,7 @@ public IObservableAsync SyncLatest( new(src1, src2, src3, src4, src5, src6), selector); - /// - /// Combines the latest values from 6 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 6 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest7.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest7.cs index 90835fc6..c9ff4d3f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest7.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest7.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 7 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 7 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -46,10 +43,7 @@ public IObservableAsync SyncLatest( new(src1, src2, src3, src4, src5, src6, src7), selector); - /// - /// Combines the latest values from 7 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 7 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs index aac19c3b..ed04162c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest8.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 8 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 8 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -54,10 +51,7 @@ public IObservableAsync SyncLatest new(src1, src2, src3, src4, src5, src6, src7, src8), selector); - /// - /// Combines the latest values from 8 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 8 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs index c2c38f5c..9a5c9a4d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest9.cs @@ -15,10 +15,7 @@ public static partial class SignalAsyncExtensions /// Source observable 1 whose latest value is combined. extension(IObservableAsync src1) { - /// - /// Combines the latest values from 9 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 9 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. @@ -57,10 +54,7 @@ public IObservableAsync SyncLatest - /// Combines the latest values from 9 asynchronous observable sources into a single - /// sequence, projecting them through whenever any source emits. - /// + /// Combines the latest values from 9 asynchronous observable sources into a single sequence, projecting them through whenever any source emits. /// The element type of source 2. /// The element type of source 3. /// The element type of source 4. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs index 21df8a1d..3457e33c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs @@ -16,11 +16,7 @@ public static partial class SignalAsyncExtensions /// An observable sequence that emits a snapshot of the latest values whenever any source produces a new value, /// after all sources have produced at least one value. /// Thrown if is . - /// Each emitted is a reference to one buffer owned by the subscription, - /// not a fresh allocation. An observer must consume the snapshot inside its OnNextAsync handler: the buffer - /// is overwritten under the operator's gate before each emit, so a retained reference surfaces the next emission's - /// values. For a stable copy, use the projecting CombineLatest overload or - /// .Select(static s => s.ToArray()). + /// Snapshots share a subscription-owned buffer. Consume them inside OnNextAsync or copy them before retaining them; subsequent emissions overwrite the buffer. public IObservableAsync> SyncLatest() { ArgumentExceptionHelper.ThrowIfNull(sources); @@ -29,10 +25,7 @@ public IObservableAsync> SyncLatest() return new SyncLatestEnumerableSignal>(sources, static s => s); } - /// - /// Combines the latest value from each asynchronous observable sequence in the supplied collection and projects the - /// resulting snapshot into a result value. - /// + /// Combines the latest value from each asynchronous observable sequence in the supplied collection and projects the resulting snapshot into a result value. /// The projected result type. /// A selector that projects the current snapshot of latest values into a result value. /// An observable sequence that emits projected results whenever any source produces a new value, after all diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs index b7437840..3c26f71e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs @@ -18,10 +18,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Returns an observable sequence that emits items from the source sequence until the specified other - /// observable emits an item or completes. - /// + /// Returns an observable sequence that emits items from the source sequence until the specified other observable emits an item or completes. /// The type of the elements in the other observable sequence that triggers termination of the source sequence. /// The observable sequence whose first emission or completion will cause the returned sequence to stop emitting /// items from the source. @@ -36,10 +33,7 @@ public IObservableAsync TakeUntil(IObservableAsync other) return new TakeUntilAsyncSignal(source, other, TakeUntilOptions.Default); } - /// - /// Returns an observable sequence that emits items from the source sequence until the specified other - /// observable emits an item or completes. - /// + /// Returns an observable sequence that emits items from the source sequence until the specified other observable emits an item or completes. /// The type of the elements in the other observable sequence that triggers termination of the source sequence. /// The observable sequence whose first emission or completion will cause the returned sequence to stop emitting /// items from the source. @@ -162,10 +156,7 @@ public IObservableAsync TakeUntil( : new TaskStopSignal(source, task, options ?? TakeUntilOptions.Default); } - /// - /// Returns an observable sequence that emits items from the source sequence until the specified cancellation - /// token is canceled. - /// + /// Returns an observable sequence that emits items from the source sequence until the specified cancellation token is canceled. /// A cancellation token that, when canceled, will terminate the resulting observable sequence. /// An observable sequence that completes when the provided cancellation token is canceled or when the source /// sequence completes. @@ -204,10 +195,7 @@ public IObservableAsync TakeUntil(Func predicate, CancellationToken : new PredicateStopSignal(source, predicate); } - /// - /// Returns an observable sequence that emits elements from the source sequence until the specified asynchronous - /// predicate returns true for an element. - /// + /// Returns an observable sequence that emits elements from the source sequence until the specified asynchronous predicate returns true for an element. /// A function that evaluates each element and its associated cancellation token asynchronously. The sequence /// stops emitting elements when this function returns true. /// An observable sequence that contains the elements from the source sequence up to, but not including, the @@ -491,15 +479,12 @@ internal async ValueTask SubscribeSourcesAsync(CancellationToken cancellationTok internal void LinkExternalCancellation(CancellationToken external) => _lifecycle.LinkExternalCancellation(external); - /// Hands the stop delegate the callback that ends the sequence. - /// Completion runs on the thread that invokes the callback, so a synchronous notify ends the - /// sequence before it returns whenever the downstream observer completes synchronously. + /// Completes on the notifying thread when downstream completion is synchronous. internal void AwaitStopThenComplete() { Volatile.Write(ref _stopRegistration, _parent._stopSignal(Stop)); - // A delegate is free to notify before it returns, in which case Stop ran without a - // registration to release. Release it here so the stop source is never left attached. + // Release registrations returned after a synchronous stop notification. if (Volatile.Read(ref _stopSignalled) != 1) { return; diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs index 52af3b00..293507ef 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Tap.cs @@ -45,10 +45,7 @@ public IObservableAsync Tap( Action onCompleted) => new TapSyncSignal(source, onNext, onError, _ => onCompleted()); - /// - /// Invokes the specified asynchronous actions for each element, error, or completion notification in the - /// observable sequence without modifying the sequence. - /// + /// Invokes the specified asynchronous actions for each element, error, or completion notification in the observable sequence without modifying the sequence. /// An asynchronous callback to invoke for each element in the sequence. /// An optional asynchronous callback to invoke if an error occurs in the sequence. /// An optional asynchronous callback to invoke when the sequence completes. @@ -67,10 +64,7 @@ onNext is null && onErrorResume is null && onCompleted is null public IObservableAsync Do(Func? onNext) => onNext is null ? source : new TapAsyncSignal(source, onNext, null, null); - /// - /// Invokes the specified actions in response to notifications from the observable sequence without modifying - /// the sequence itself. - /// + /// Invokes the specified actions in response to notifications from the observable sequence without modifying the sequence itself. /// An action to invoke for each element in the sequence as it is emitted. /// An action to invoke if an error occurs in the sequence. /// An action to invoke when the sequence completes, receiving the final result. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs index b0002459..86576174 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs @@ -5,10 +5,7 @@ namespace ReactiveUI.Primitives.Async; /// Provides Throttle (debounce) extension methods for asynchronous observable sequences. -/// Throttle ignores elements from the source sequence that are followed by another element -/// within a specified time span. Only values that are not followed by another value within the due time -/// are forwarded to observers. This is commonly used to suppress rapid bursts of events such as keystrokes -/// or mouse movements. +/// Only the latest value is emitted after the quiet period; each source value restarts that period. public static partial class SignalAsyncExtensions { /// Throttle (debounce) operators for an observable source sequence. @@ -16,10 +13,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Ignores elements from the source sequence that are followed by another element within - /// the specified time span. Only the last element in each burst is forwarded. - /// + /// Ignores elements from the source sequence that are followed by another element within the specified time span. Only the last element in each burst is forwarded. /// The time span that must elapse after the last element before it is forwarded. /// Must be non-negative. /// An observable sequence containing only those elements that are not followed by another @@ -32,10 +26,7 @@ public IObservableAsync Throttle(TimeSpan dueTime) return new ThrottleSignal(source, dueTime, TimeProvider.System); } - /// - /// Ignores elements from the source sequence that are followed by another element within - /// the specified time span. Only the last element in each burst is forwarded. - /// + /// Ignores elements from the source sequence that are followed by another element within the specified time span. Only the last element in each burst is forwarded. /// The time span that must elapse after the last element before it is forwarded. /// Must be non-negative. /// An optional time provider for controlling timing. If null, @@ -55,17 +46,10 @@ public IObservableAsync Throttle(TimeSpan dueTime, TimeProvider? timeProvider /// The duration to delay. extension(TimeSpan delay) { - /// Asynchronously delays for the specified duration using the provided time provider. + /// Creates a cancellable delay using the supplied time provider. /// The time provider to use for the delay. /// A token to cancel the delay. /// A that completes after the specified delay. - /// - /// For the result is a wrapper around - /// ; for custom providers the call rents a - /// pooled so the per-call - /// + + allocation chain - /// from the legacy implementation collapses to zero on the steady path. - /// internal ValueTask DelayAsync( TimeProvider timeProvider, CancellationToken cancellationToken) => @@ -74,10 +58,7 @@ internal ValueTask DelayAsync( : PooledDelaySource.Rent().BeginAsync(delay, timeProvider, cancellationToken); } - /// - /// Async observable that debounces the source sequence, only forwarding elements that are not - /// followed by another element within the specified due time. - /// + /// Async observable that debounces the source sequence, only forwarding elements that are not followed by another element within the specified due time. /// The type of elements in the sequence. /// The source observable sequence to throttle. /// The quiet period that must elapse before an element is forwarded. @@ -96,10 +77,7 @@ ValueTask IObservableAsync.SubscribeAsync( return source.SubscribeAsync(throttleObserver, cancellationToken); } - /// - /// Observer that implements throttle/debounce logic by starting a timer on each element - /// and only forwarding the element if no newer element supersedes it before the timer fires. - /// + /// Delays each value and forwards it only if no newer value supersedes it. /// The downstream observer to forward debounced elements to. /// The quiet period that must elapse before an element is forwarded. /// The time provider used for scheduling the debounce timer. @@ -111,6 +89,21 @@ internal sealed class ThrottleWitness(IObserverAsync observer, TimeSpan dueTi /// A monotonically increasing identifier used to detect whether a newer element has superseded the current timer. private long _id; + /// Starts a debounce delay with a fresh identifier. + /// The value to forward if it remains current. + /// Cancellation for the delay. + /// The delay and notification operation. + internal Task StartDelayAsync(T value, CancellationToken cancellationToken) + { + long currentId; + lock (_gate) + { + currentId = ++_id; + } + + return FireAfterDelayAsync(value, currentId, cancellationToken); + } + /// Waits for the debounce delay and then forwards the value if it has not been superseded. /// The value to forward after the delay. /// The identifier of this timer; if superseded by a newer id, the value is discarded. @@ -140,26 +133,13 @@ internal async Task FireAfterDelayAsync(T value, long id, CancellationToken canc } } - /// - /// Starts a new debounce timer for the received element, identifying it by a fresh id. - /// Supersession is detected post-delay via the id check rather than via a per-emission - /// linked CTS — eliminating the Linked1CancellationTokenSource allocation that - /// dominated the operator's GC profile. A superseded delay still runs to completion - /// (waiting the full dueTime) but its result is discarded, which trades a small - /// amount of transient state-machine retention for zero per-emission allocation. - /// + /// Schedules the value's delay and discards completion if a newer value supersedes it. /// The element to potentially forward after the debounce period. /// A token to cancel the operation. /// A completed task; the actual forwarding happens asynchronously after the delay. protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken) { - long currentId; - lock (_gate) - { - currentId = ++_id; - } - - _ = FireAfterDelayAsync(value, currentId, cancellationToken); + _ = StartDelayAsync(value, cancellationToken); return default; } @@ -190,10 +170,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) return observer.OnCompletedAsync(result); } - /// - /// Marks any in-flight delay as superseded during disposal. The dispose token threaded - /// through by the base observer also unblocks the awaits. - /// + /// Marks any in-flight delay as superseded during disposal. The dispose token threaded through by the base observer also unblocks the awaits. /// A completed task. protected override ValueTask DisposeAsyncCore() { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs index d5df1d39..25bee995 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs @@ -16,10 +16,7 @@ public static partial class SignalAsyncExtensions /// The asynchronous observable to convert into an asynchronous enumerable. extension(IObservableAsync source) { - /// - /// Converts the specified asynchronous observable sequence to an asynchronous enumerable sequence, enabling - /// consumption using asynchronous iteration. - /// + /// Converts the specified asynchronous observable sequence to an asynchronous enumerable sequence, enabling consumption using asynchronous iteration. /// A factory function that produces a channel to buffer elements, controlling /// the buffering and backpressure behavior between the asynchronous observable and the asynchronous enumerable. /// An asynchronous enumerable sequence that yields elements from the asynchronous observable. The @@ -36,10 +33,7 @@ public IAsyncEnumerable ToAsyncEnumerable( Func> channelFactory) => source.ToAsyncEnumerable(channelFactory, null); - /// - /// Converts the specified observable sequence to an asynchronous enumerable sequence, enabling consumption using - /// asynchronous iteration. - /// + /// Converts the specified observable sequence to an asynchronous enumerable sequence, enabling consumption using asynchronous iteration. /// A factory function that creates a new channel used to buffer items between the observable and the asynchronous /// enumerable. The channel controls the buffering and backpressure behavior. /// An optional asynchronous callback invoked when an error occurs in the observable sequence. If provided, this @@ -48,10 +42,7 @@ public IAsyncEnumerable ToAsyncEnumerable( /// An asynchronous enumerable sequence that yields the elements produced by the observable sequence. The /// enumeration completes when the observable completes or an unhandled error occurs. /// Thrown if or is null. - /// The returned asynchronous enumerable reflects the items and completion behavior of the source - /// observable. The buffering and concurrency characteristics depend on the channel created by . If is provided, it can be used to suppress or handle - /// errors from the observable; otherwise, errors are propagated to the enumerator. + /// The channel factory controls buffering. The optional error callback handles source errors; without it, errors propagate to the enumerator. public IAsyncEnumerable ToAsyncEnumerable( Func> channelFactory, Func? onErrorResume) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs index fb7f8918..13b96143 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs @@ -7,10 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for asynchronously converting an observable sequence to a dictionary. -/// The methods in this class enable the transformation of an asynchronous observable sequence into a -/// dictionary, using user-supplied key and element selector functions. These operations are performed asynchronously -/// and support cancellation via a CancellationToken. All methods throw an exception if duplicate keys are encountered -/// in the source sequence, consistent with the behavior of Dictionary{TKey, TValue}. public static partial class SignalAsyncExtensions { /// Asynchronous dictionary-materialization operators for an observable source sequence. @@ -35,10 +31,7 @@ public ValueTask> ToDictionaryAsync( where TKey : notnull => ToDictionaryCore(source, keySelector, DictionaryIdentity.Instance, comparer, cancellationToken); - /// - /// Asynchronously creates a dictionary from the elements of the sequence, using the specified key selector - /// function and the default equality comparer for the key type. - /// + /// Asynchronously creates a dictionary from the elements of the sequence, using the specified key selector function and the default equality comparer for the key type. /// The type of the keys in the resulting dictionary. Must be non-nullable. /// A function to extract a key from each element in the sequence. Cannot be null. /// A task that represents the asynchronous operation. The task result contains a dictionary mapping keys to @@ -49,10 +42,7 @@ public ValueTask> ToDictionaryAsync(Func keyS where TKey : notnull => source.ToDictionaryAsync(keySelector, null, CancellationToken.None); - /// - /// Asynchronously creates a dictionary from the elements of the sequence using the specified key and element - /// selector functions. - /// + /// Asynchronously creates a dictionary from the elements of the sequence using the specified key and element selector functions. /// The type of the keys in the resulting dictionary. Must be non-nullable. /// The type of the values in the resulting dictionary. /// A function to extract a key from each element in the sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ToListAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ToListAsync.cs index b883f87c..89e4c23b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ToListAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ToListAsync.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for working with asynchronous observable sequences. -/// The SignalAsync class contains static methods that extend the functionality of asynchronous -/// observables, enabling operations such as materializing the sequence into a list asynchronously. These methods are -/// intended to simplify common tasks when consuming asynchronous observable streams. public static partial class SignalAsyncExtensions { /// Asynchronous list-materialization operators for an observable source sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs index dcc71d0d..ed06637c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs @@ -4,13 +4,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides extension methods for working with asynchronous observable sequences, enabling operations such as -/// suppressing consecutive duplicate elements. -/// -/// The methods in this class allow developers to filter out consecutive duplicates in observable -/// sequences, either by value or by a specified key. These operations are useful for scenarios where only changes or -/// distinct consecutive values are of interest, such as event streams or state change notifications. +/// Provides extension methods for working with asynchronous observable sequences, enabling operations such as suppressing consecutive duplicate elements. public static partial class SignalAsyncExtensions { /// Consecutive-distinctness operators for an observable source sequence. @@ -18,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Returns an observable sequence that emits only distinct consecutive elements, suppressing duplicates that - /// are equal to the previous element. - /// + /// Returns an observable sequence that emits only distinct consecutive elements, suppressing duplicates that are equal to the previous element. /// An observable sequence that contains only the elements from the source sequence that are not equal to their /// immediate predecessor. /// Elements are compared using the default equality comparer for the type Unique(IEqualityComparer equalityComparer) return new UniqueSignal(source, equalityComparer); } - /// - /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive - /// duplicates as determined by a key selector function. - /// + /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive duplicates as determined by a key selector function. /// The type of the key used to determine whether consecutive elements are considered duplicates. /// A function that extracts the comparison key from each element in the source sequence. /// An observable sequence that contains only the elements from the source sequence that are not consecutive @@ -72,10 +60,7 @@ public IObservableAsync UniqueBy(Func keySelector) return new UniqueBySignal(source, keySelector, EqualityComparer.Default); } - /// - /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive - /// duplicates as determined by a key selector and equality comparer. - /// + /// Returns an observable sequence that emits elements from the source sequence, suppressing consecutive duplicates as determined by a key selector and equality comparer. /// The type of the key used to determine whether consecutive elements are considered duplicates. /// A function that extracts the comparison key from each element in the source sequence. /// An equality comparer used to compare keys for equality. @@ -97,10 +82,7 @@ public IObservableAsync UniqueBy( } } - /// - /// Single-observer-layer DistinctUntilChanged. Replaces the previous \c Create + async-lambda + closure - /// pattern; per-subscription state lives in observer fields. - /// + /// Single-observer-layer DistinctUntilChanged. Replaces the previous \c Create + async-lambda + closure pattern; per-subscription state lives in observer fields. /// The element type. /// The upstream observable. /// The equality comparer used to detect duplicates. @@ -161,10 +143,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) => } } - /// - /// Single-observer-layer DistinctUntilChangedBy; key is extracted once per emission and compared - /// against the most-recently-forwarded key. - /// + /// Single-observer-layer DistinctUntilChangedBy; key is extracted once per emission and compared against the most-recently-forwarded key. /// The element type. /// The key type. /// The upstream observable. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs index d61db55f..56e3b6ff 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs @@ -7,15 +7,9 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating and composing asynchronous observable sequences. -/// The SignalAsync class contains static methods for working with asynchronous observables, enabling -/// resource management and composition patterns similar to those found in reactive programming. All members are -/// thread-safe and intended for use in asynchronous and reactive scenarios. public static partial class SignalAsync { - /// - /// Creates an observable sequence that manages the lifetime of an asynchronous resource, ensuring the resource is - /// disposed when the sequence terminates. - /// + /// Creates an observable sequence that manages the lifetime of an asynchronous resource, ensuring the resource is disposed when the sequence terminates. /// The type of the asynchronous resource that implements . /// The type of the elements produced by the observable sequence. /// A function that asynchronously creates the resource to be used by the observable sequence. The function receives @@ -32,10 +26,7 @@ public static IObservableAsync Use( where TResource : IAsyncDisposable => new UsingSignal(resourceFactory, signalFactory); - /// - /// Creates an observable sequence that manages the lifetime of an asynchronous resource, ensuring the resource is - /// disposed when the sequence terminates. - /// + /// Creates an observable sequence that manages the lifetime of an asynchronous resource, ensuring the resource is disposed when the sequence terminates. /// The type of the elements produced by the observable sequence. /// The type of the asynchronous resource that implements . /// A function that asynchronously creates the resource to be used by the observable sequence. @@ -43,11 +34,7 @@ public static IObservableAsync Use( /// signal sequence that uses the resource. /// An observable that uses the specified resource and ensures the resource is disposed /// asynchronously when the sequence completes or an error occurs. - /// The resource is created for each subscription and is disposed asynchronously when the - /// observable sequence terminates, either by completion or error. If the observable factory throws an exception, - /// the resource is disposed before the exception is propagated. This method is useful for managing resources that - /// must be disposed when no longer needed, such as streams or database connections, in conjunction with - /// asynchronous observable sequences. + /// Each subscription owns its resource. Completion, error, or a throwing observable factory disposes that resource asynchronously. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net10.0/PublicAPI.txt index 0eea0d9b..aefd152b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net10.0/PublicAPI.txt @@ -878,6 +878,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1658,7 +1659,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1700,13 +1701,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net11.0/PublicAPI.txt index 0eea0d9b..aefd152b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net11.0/PublicAPI.txt @@ -878,6 +878,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1658,7 +1659,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1700,13 +1701,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net462/PublicAPI.txt index e8e88287..2d1d11d2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net462/PublicAPI.txt @@ -879,6 +879,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1659,7 +1660,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1701,13 +1702,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net472/PublicAPI.txt index e8e88287..2d1d11d2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net472/PublicAPI.txt @@ -879,6 +879,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1659,7 +1660,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1701,13 +1702,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net48/PublicAPI.txt index e8e88287..2d1d11d2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net48/PublicAPI.txt @@ -879,6 +879,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1659,7 +1660,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1701,13 +1702,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net481/PublicAPI.txt index e8e88287..2d1d11d2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net481/PublicAPI.txt @@ -879,6 +879,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1659,7 +1660,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1701,13 +1702,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net8.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net8.0/PublicAPI.txt index 0eea0d9b..aefd152b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net8.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net8.0/PublicAPI.txt @@ -878,6 +878,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1658,7 +1659,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1700,13 +1701,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net9.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net9.0/PublicAPI.txt index 0eea0d9b..aefd152b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net9.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Core/PublicAPI/net9.0/PublicAPI.txt @@ -878,6 +878,7 @@ namespace ReactiveUI.Primitives.Async.Advanced public void Dispose() { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync() { } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public System.Threading.Tasks.ValueTask EnterAsync(System.Threading.CancellationToken cancellationToken) { } [System.Diagnostics.DebuggerDisplay("Lease: Parent = {_parent}")] public readonly record struct Lease : System.IDisposable, System.IEquatable @@ -1658,7 +1659,7 @@ namespace ReactiveUI.Primitives.Async.Disposables public static System.Threading.Tasks.ValueTask DisposeAsync(ref System.IAsyncDisposable? slot) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsDisposed(System.IAsyncDisposable? slot) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static System.Threading.Tasks.ValueTask SwapAsync(ref System.IAsyncDisposable? slot, System.IAsyncDisposable? value) { } } [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -1700,13 +1701,12 @@ namespace ReactiveUI.Primitives.Async.Disposables } namespace ReactiveUI.Primitives.Async.Helpers { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class DisposalHelper { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class FireAndForgetHelper { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static void Run(System.Func action) { } } public static class SubscriptionHelper diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs index ec236420..4188c29c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// Provides helper methods for forwarding asynchronous observer notifications concurrently to multiple observers. -/// The methods in this class are intended for scenarios where multiple asynchronous observers need to be -/// notified in parallel. All observer notifications are dispatched concurrently, and the returned ValueTask completes -/// when all observer operations have finished. If the observers collection is empty, the methods complete immediately. -/// Exceptions thrown by individual observers are aggregated into a single exception, consistent with Task.WhenAll -/// behavior. +/// All observers start concurrently; the returned task waits for every observer. Empty collections complete synchronously, and failures follow Task.WhenAll semantics. public static class Concurrent { /// Forwards the specified value to all observers concurrently by invoking their OnNextAsync methods. @@ -22,8 +18,7 @@ public static class Concurrent /// A cancellation token that can be used to cancel the forwarding operation. /// A ValueTask that represents the asynchronous operation of forwarding the value to all observers. The task /// completes when all observers have processed the value. - /// If the observers list is empty, the returned ValueTask is already completed. All OnNextAsync - /// calls are started concurrently; exceptions from observers are aggregated in the returned task. + /// Empty collections complete synchronously. Multiple observers follow Task.WhenAll failure semantics. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ValueTask ForwardOnNextConcurrently( ImmutableArray> observers, @@ -62,19 +57,14 @@ public static ValueTask ForwardOnNextConcurrently( return default; } - /// - /// Forwards an error notification to all specified asynchronous observers concurrently, allowing each observer to - /// handle the error and resume as appropriate. - /// + /// Forwards an error notification to all specified asynchronous observers concurrently, allowing each observer to handle the error and resume as appropriate. /// The type of the elements observed by the observers. /// A read-only list of asynchronous observers to which the error notification will be forwarded. Cannot be null. /// The exception representing the error to forward to each observer. Cannot be null. /// A cancellation token that can be used to cancel the forwarding operation. /// A ValueTask that represents the asynchronous operation of forwarding the error to all observers. The task /// completes when all observers have processed the error notification. - /// If the list of observers is empty, the method returns a default ValueTask and no - /// notifications are sent. Each observer receives the error notification concurrently. If cancellation is requested - /// via the cancellation token, the operation may be canceled before completion. + /// Cancellation is forwarded to each observer; it does not prevent other observers from being called. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ValueTask ForwardOnErrorResumeConcurrently( ImmutableArray> observers, @@ -113,19 +103,14 @@ public static ValueTask ForwardOnErrorResumeConcurrently( return default; } - /// - /// Invokes the OnCompletedAsync method on each observer in the collection concurrently, forwarding the specified - /// result to all observers. - /// + /// Invokes the OnCompletedAsync method on each observer in the collection concurrently, forwarding the specified result to all observers. /// The type of the elements observed by the observers. /// A read-only list of observers to which the completion notification will be forwarded. Cannot be null. /// The result to pass to each observer's OnCompletedAsync method. /// A ValueTask that represents the asynchronous operation of notifying all observers. The task completes when all /// observers have finished processing the completion notification. If the observers list is empty, a default /// ValueTask is returned. - /// All observers are notified concurrently. The returned ValueTask completes when all - /// OnCompletedAsync operations have finished. If any observer throws an exception, the returned task will complete - /// with an AggregateException containing all exceptions thrown. + /// Empty collections complete synchronously. Multiple observers follow Task.WhenAll failure semantics. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ValueTask ForwardOnCompletedConcurrently( ImmutableArray> observers, diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs index 0a1e752f..a0ab9527 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs @@ -2,8 +2,6 @@ // 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.Primitives.Async.Signals; /// Subscription handle that removes an observer from a replay signal when disposed. @@ -25,7 +23,6 @@ public ValueTask DisposeAsync() => /// Removes the observer from the replay signal under the serialization gate. /// A task that represents the asynchronous removal operation. - [ExcludeFromCodeCoverage] private async ValueTask RemoveObserverAsync() { if (state.IsDisposed) diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs index f1e4f54c..af676fe6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs @@ -29,11 +29,7 @@ public ReplayLatestSignalAsyncState(Optional initialValue) /// Gets the cancellation token source that is cancelled when this instance is disposed. internal CancellationTokenSource DisposedCts { get; } = new(); - /// Gets the token cancelled when this instance is disposed. Captured while the source is still - /// alive because disposes that source, and reading - /// from a disposed source throws - /// . Disposal always cancels before it disposes, so this token is - /// already cancelled by the time anyone can observe it post-disposal. + /// Gets the disposal token, which remains accessible after the source is disposed. internal CancellationToken DisposedCancellationToken { get; } /// Gets or sets the most recently published value, replayed to new subscribers upon subscription. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs index 9e894068..35870106 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs @@ -8,21 +8,10 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Shared serial-broadcast loops for the Serial* Signal family. The body of each Signal's -/// OnNextAsyncCore / OnErrorResumeAsyncCore / OnCompletedAsyncCore is identical: -/// iterate the observer snapshot and await each call in turn. Centralising the loops here keeps the -/// hot-path single-observer fast-path inlined at the call site while removing the duplicated -/// multi-observer body across four Signal classes. Methods are static so there is no virtual -/// dispatch and no extra heap allocation per emission. -/// +/// Delivers notifications to each observer in a snapshot, awaiting them in order. internal static class SerialBroadcastHelpers { - /// - /// Single-observer fast path delegates directly to the observer's OnNextAsync; the - /// multi-observer case forwards to , where the async - /// state machine is only used when an observer actually suspends. - /// + /// Forwards directly to a single observer or awaits multiple observers in order. /// The element type. /// The current observer snapshot. /// The value being broadcast. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs index 195d7460..1a6a1de9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs @@ -2,8 +2,6 @@ // 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.Primitives.Async.Signals; /// Subscription handle that removes an observer from a stateless replay signal when disposed. @@ -25,7 +23,6 @@ public ValueTask DisposeAsync() => /// Removes the observer and restores the initial value when the last observer leaves. /// A task that represents the asynchronous removal operation. - [ExcludeFromCodeCoverage] private async ValueTask RemoveObserverAndResetAsync() { if (state.IsDisposed) diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs index 9a0c3c1c..2d64e4d1 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs @@ -30,11 +30,7 @@ public StatelessReplayLatestSignalAsyncState(Optional initialValue) /// Gets the cancellation token source that is cancelled when this instance is disposed. internal CancellationTokenSource DisposedCts { get; } = new(); - /// Gets the token cancelled when this instance is disposed. Captured while the source is still - /// alive because disposes that source, and reading - /// from a disposed source throws - /// . Disposal always cancels before it disposes, so this token is - /// already cancelled by the time anyone can observe it post-disposal. + /// Gets the disposal token, which remains accessible after the source is disposed. internal CancellationToken DisposedCancellationToken { get; } /// Gets the initial value to replay, if any. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs index a1254204..52e5f8c2 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs @@ -6,15 +6,10 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents an asynchronous Signal that replays only the latest value to new observers and supports concurrent -/// notification of observers. -/// +/// Represents an asynchronous Signal that replays only the latest value to new observers and supports concurrent notification of observers. /// The type of the elements processed by the Signal. /// An optional initial value to be emitted to observers upon subscription if no other value has been published. -/// This Signal notifies all observers concurrently, which can improve throughput in scenarios with -/// multiple observers. The order in which observers receive notifications is not guaranteed. This type is thread-safe -/// and suitable for use in asynchronous and concurrent environments. +/// Observer notifications overlap; their completion order is unspecified. [System.Diagnostics.DebuggerDisplay("ConcurrentReplayLatestSignalAsync: LastValue = {_state.LastValue}, IsDisposed = {_state.IsDisposed}")] public sealed class ConcurrentReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs index 83a4f1c2..892b5425 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs @@ -8,9 +8,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// Provides an asynchronous Signal that forwards notifications to observers concurrently. /// The type of value observed and forwarded to observers. -/// Observers are notified in parallel for each event. This class is suitable for scenarios where high -/// throughput and concurrent notification of multiple observers are required. Thread safety is ensured for observer -/// notification operations. Cancellation tokens can be used to cancel ongoing notification tasks. +/// Observer notifications overlap; their completion order is unspecified. [System.Diagnostics.DebuggerDisplay("ConcurrentSignalAsync: Observers = {_state.Observers.Length}, Result = {_state.Result}")] public sealed class ConcurrentSignalAsync : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs index 203f7690..d1a95f2f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs @@ -6,17 +6,11 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents an asynchronous Signal that replays the latest value to new observers and forwards notifications to all -/// observers concurrently without maintaining internal state. -/// +/// Represents an asynchronous Signal that replays the latest value to new observers and forwards notifications to all observers concurrently without maintaining internal state. /// The type of the elements processed by the Signal. /// An optional initial value to be replayed to new observers. If not specified, no value is replayed until the first /// value is published. -/// This Signal is designed for concurrent scenarios where notifications to observers should be -/// delivered in parallel. It does not buffer or store a sequence of values, but only replays the most recent value (if -/// any) to new subscribers. Thread safety is ensured for concurrent observer notifications. If a notification operation -/// is canceled, not all observers may receive the notification. +/// Replays the latest value to new subscribers and notifies observers concurrently. Cancellation can prevent some observers from receiving a notification. [System.Diagnostics.DebuggerDisplay("ConcurrentStatelessReplayLatestSignalAsync: Value = {_state.Value}, IsDisposed = {_state.IsDisposed}")] public sealed class ConcurrentStatelessReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs index 6e6cfa45..6a7042b7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs @@ -8,10 +8,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// Represents a stateless asynchronous Signal that forwards notifications to observers concurrently. /// The type of the elements processed by the Signal. -/// This Signal distributes notifications to all subscribed observers in parallel, allowing for improved -/// throughput in scenarios where observer processing can occur independently. Use this type when observer notification -/// order is not important and concurrent delivery is desired. Thread safety is ensured for concurrent observer -/// notifications. +/// Observer notifications execute concurrently; their completion order is unspecified. [System.Diagnostics.DebuggerDisplay("ConcurrentStatelessSignalAsync: Observers = {_state.Observers.Length}")] public sealed class ConcurrentStatelessSignalAsync : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ISignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ISignalAsync.cs index fe0d4288..4ed8c7ae 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ISignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ISignalAsync.cs @@ -4,19 +4,10 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents an asynchronous Signal that allows observers to receive values, errors, or completion notifications -/// asynchronously. -/// +/// Represents an asynchronous Signal that allows observers to receive values, errors, or completion notifications asynchronously. /// The type of the values observed and published by the Signal. -/// An asynchronous Signal enables push-based notification of values, errors, or completion events to -/// multiple observers. Observers can subscribe to the Signal's values stream and receive notifications as they are -/// published. This interface is typically used in scenarios where asynchronous event propagation and coordination are -/// required, such as reactive programming or event-driven architectures. public interface ISignalAsync : IObserverAsync, IObservableAsync { /// Gets an observable sequence that asynchronously provides the current values of the collection. - /// The returned sequence emits updates whenever the underlying collection changes. Subscribers - /// receive notifications asynchronously as values are added, removed, or updated. IObservableAsync Values { get; } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs index f25eb90a..b178be7e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs @@ -9,8 +9,6 @@ namespace ReactiveUI.Primitives.Async.Signals; public sealed record BehaviorSignalCreationOptions { /// Gets the default configuration options for creating a new BehaviorSignal instance. - /// Use this property to obtain a standard set of options when creating a BehaviorSignal, unless - /// custom behavior is required. public static BehaviorSignalCreationOptions Default { get; } = new() { PublishingOption = PublishingOption.Serial, IsStateless = false }; /// Gets the publishing option to use when processing the item. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs index 34f168cb..bf67718e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs @@ -4,25 +4,12 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Specifies the available options for publishing operations, indicating whether actions are performed serially or -/// concurrently. -/// -/// Use this enumeration to control the execution mode of publishing processes. Selecting 'Serial' -/// ensures that operations are performed one after another, while 'Concurrent' allows multiple operations to be -/// executed in parallel. The appropriate option depends on the requirements for performance and resource management in -/// your application. +/// Specifies the available options for publishing operations, indicating whether actions are performed serially or concurrently. public enum PublishingOption { - /// Represents a serial communication interface for transmitting and receiving data over serial ports. - /// Use this type to interact with devices that communicate via serial protocols, such as RS-232 - /// or UART. Serial communication is commonly used for connecting to hardware peripherals, embedded systems, or - /// legacy equipment. Thread safety and supported features may vary depending on the implementation. + /// Awaits each observer notification before invoking the next observer. Serial = 0, - /// Provides thread-safe operations for managing shared resources in concurrent programming scenarios. - /// Use this type to coordinate access to data or resources that may be accessed by multiple - /// threads simultaneously. Thread safety is ensured for all public members, allowing safe usage in multi-threaded - /// environments without additional synchronization. + /// Invokes observer notifications concurrently. Concurrent = 1, } diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs index 62cc613c..b72f9410 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs @@ -4,20 +4,12 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents configuration options for creating a replay-latest Signal, controlling publishing behavior and state -/// retention. -/// -/// Use this type to specify how a replay-latest Signal should handle message publishing and whether it -/// maintains state. The options provided affect how subscribers receive messages and whether the Signal retains the -/// latest value. This type is immutable and can be used to configure Signal creation in a thread-safe -/// manner. +/// Represents configuration options for creating a replay-latest Signal, controlling publishing behavior and state retention. [System.Diagnostics.DebuggerDisplay("ReplayLatestSignalCreationOptions: PublishingOption = {PublishingOption}, IsStateless = {IsStateless}")] public sealed record ReplayLatestSignalCreationOptions { /// Gets the default configuration options for creating a ReplayLatestSignal instance. - /// The default options specify serial publishing and stateful behavior. Use this property to - /// obtain a baseline configuration that can be modified as needed. + /// Defaults to serial, stateful publishing. public static ReplayLatestSignalCreationOptions Default { get; } = new() { PublishingOption = PublishingOption.Serial, IsStateless = false }; /// Gets the publishing option to use when processing this item. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs index 2f70cf92..06023822 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs @@ -4,19 +4,12 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents the set of options used when creating a Signal, including publishing behavior and state management -/// requirements. -/// -/// Use this type to configure how a Signal is created, specifying whether it should be stateless and -/// which publishing option to apply. The options provided affect the Signal's behavior and lifecycle. This record is -/// immutable and can be used to ensure consistent Signal creation across different parts of an application. +/// Represents the set of options used when creating a Signal, including publishing behavior and state management requirements. [System.Diagnostics.DebuggerDisplay("SignalCreationOptions: PublishingOption = {PublishingOption}, IsStateless = {IsStateless}")] public sealed record SignalCreationOptions { /// Gets the default configuration for Signal creation options. - /// The default options use serial publishing and are not stateless. Use this property as a - /// baseline when configuring new Signals if standard behavior is desired. + /// Defaults to serial, stateful publishing. public static SignalCreationOptions Default { get; } = new() { PublishingOption = PublishingOption.Serial, IsStateless = false }; /// Gets the publishing option to use when processing the item. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs index 1129e1c9..6ace90eb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs @@ -12,11 +12,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// /// The type of the elements processed by the Signal. /// An optional initial value to be emitted to new subscribers before any other values are published. -/// This Signal is designed for scenarios where only the most recent value is relevant to subscribers. -/// When a new observer subscribes, it immediately receives the latest value (if any) and then all subsequent -/// notifications. All observer notifications are performed asynchronously and in a serial order, ensuring thread -/// safety. This type is suitable for use cases where replaying only the latest value is desired, such as event streams -/// or state broadcasts. +/// New subscribers receive the latest value, if present. Each observer notification is awaited before invoking the next observer. [System.Diagnostics.DebuggerDisplay("SerialReplayLatestSignalAsync: LastValue = {_state.LastValue}, IsDisposed = {_state.IsDisposed}")] public sealed class SerialReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs index bca7d516..bf1b0429 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs @@ -6,14 +6,9 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents an asynchronous Signal that notifies observers in a serial manner, ensuring each observer is notified -/// one at a time. -/// +/// Represents an asynchronous Signal that notifies observers in a serial manner, ensuring each observer is notified one at a time. /// The type of the elements processed and observed by the Signal. -/// SerialSignalAsync{T} is designed for scenarios where observers must be notified sequentially rather -/// than concurrently. This can be useful when observer operations are not thread-safe or when order of notification is -/// important. Notifications to observers are performed asynchronously and in sequence. +/// Each observer notification finishes before the next observer is called. [System.Diagnostics.DebuggerDisplay("SerialSignalAsync: Observers = {_state.Observers.Length}, Result = {_state.Result}")] public sealed class SerialSignalAsync : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs index aca8d563..91c2a6cc 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs @@ -6,17 +6,11 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents a serial, stateless asynchronous Signal that replays only the last value to new observers and supports -/// asynchronous notification delivery. -/// +/// Represents a serial, stateless asynchronous Signal that replays only the last value to new observers and supports asynchronous notification delivery. /// The type of the elements processed by the Signal. /// An optional initial value to be replayed to new observers before any values are published. If not specified, no /// value is replayed until the first value is received. -/// This Signal delivers notifications to observers one at a time in the order they are received. It -/// does not maintain any state beyond the most recent value, and only the last value (if any) is replayed to new -/// subscribers. All observer notifications are dispatched asynchronously and serially, ensuring that each observer -/// receives notifications in the correct order. +/// Replays the latest value, if present, and awaits each observer notification in order. [System.Diagnostics.DebuggerDisplay("SerialStatelessReplayLatestSignalAsync: State = {_state}")] public sealed class SerialStatelessReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs index bbf71d98..6be57af8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs @@ -8,9 +8,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// Represents a stateless asynchronous Signal that notifies observers of events in a serial, sequential manner. /// The type of the elements processed and observed by the Signal. -/// Observers are notified one at a time in the order they are registered. Each observer receives the -/// event only after the previous observer has completed processing. This class is suitable for scenarios where event -/// delivery order and sequential processing are required. Thread safety and ordering are managed internally. +/// Observers are notified in registration order; each notification is awaited before the next begins. [System.Diagnostics.DebuggerDisplay("SerialStatelessSignalAsync: Observers = {_state.Observers.Length}")] public sealed class SerialStatelessSignalAsync : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs index 79f0728e..735bf65f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs @@ -7,21 +7,12 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Provides factory methods for creating asynchronous Signal instances with configurable publishing and state -/// retention behaviors. -/// -/// The Signal class offers a variety of static methods to create Signals that support different -/// publishing strategies (such as serial or concurrent) and state management options (stateful or stateless). These -/// Signals can be used to broadcast values to multiple observers in asynchronous scenarios. Use the provided creation -/// options to customize the Signal's behavior according to your application's requirements. +/// Provides factory methods for creating asynchronous Signal instances with configurable publishing and state retention behaviors. public static class Signal { /// Creates a new asynchronous Signal instance for the specified type. /// The type of elements processed by the Signal. /// An that represents the newly created asynchronous Signal. - /// The created Signal uses the default Signal creation options. Use the overload that accepts - /// to customize Signal behavior. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Design", @@ -35,9 +26,6 @@ public static class Signal /// for publishing and statelessness. /// An asynchronous Signal instance configured according to the specified options. /// Thrown if the specified combination of publishing and statelessness options is not supported. - /// Use this method to create an ISignalAsync{T} with the desired concurrency and state - /// management characteristics. The returned Signal type depends on the values provided in the options - /// parameter. [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", @@ -60,10 +48,7 @@ public static ISignalAsync Create(SignalCreationOptions? options) => public static ISignalAsync CreateBehavior(T startValue) => new SerialReplayLatestSignalAsync(new(startValue)); - /// - /// Creates a new asynchronous Signal that replays the latest value to new subscribers, using the specified initial - /// value and creation options. - /// + /// Creates a new asynchronous Signal that replays the latest value to new subscribers, using the specified initial value and creation options. /// The type of the values published by the Signal. /// The initial value to be published by the Signal before any values are pushed. /// The options that control the Signal's publishing behavior and state management. @@ -86,9 +71,7 @@ public static ISignalAsync CreateBehavior(T startValue, BehaviorSignalCrea /// Creates a new asynchronous Signal that replays only the most recent value to new subscribers. /// The type of the elements processed by the Signal. /// An asynchronous Signal that stores and replays the latest value to each new subscriber. - /// The returned Signal will only retain the most recent value published. When a new subscriber - /// subscribes, it immediately receives the latest value, if any, followed by subsequent values. This is useful for - /// scenarios where only the most recent state is relevant to new observers. + /// New subscribers receive the latest published value, if present, followed by live values. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Design", @@ -97,10 +80,7 @@ public static ISignalAsync CreateBehavior(T startValue, BehaviorSignalCrea public static ISignalAsync CreateReplayLatest() => new SerialReplayLatestSignalAsync(Optional.Empty); - /// - /// Creates a new asynchronous Signal that replays the latest value to new subscribers, with configuration options - /// for publishing behavior and statefulness. - /// + /// Creates a new asynchronous Signal that replays the latest value to new subscribers, with configuration options for publishing behavior and statefulness. /// The type of the elements processed by the Signal. /// The options that specify the publishing mode and whether the Signal maintains state. Cannot be null. /// An asynchronous Signal that replays the latest value to new subscribers, configured according to the specified diff --git a/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs b/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs index 5ea985e0..83b816d1 100644 --- a/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs +++ b/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async; -/// -/// Provides a mechanism for registering a global handler to process unhandled exceptions that occur during -/// application execution. -/// +/// Provides a mechanism for registering a global handler to process unhandled exceptions that occur during application execution. /// The process-wide default writes the exception to . Register a /// handler to log, clean up or notify instead. Cancellation is treated as normal flow and never reaches the /// handler. diff --git a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs index 3ae4e3f2..e2e0dfb1 100644 --- a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs @@ -9,8 +9,7 @@ namespace ReactiveUI.Primitives.Async; -/// Base class for an asynchronous observer: handles the notification gate, cancellation linking and disposal -/// so a derived sink only has to implement the three Core methods. +/// Base class for an asynchronous observer: handles the notification gate, cancellation linking and disposal so a derived sink only has to implement the three Core methods. /// The type of the elements received by the observer. /// One notification runs at a time. A reentrant call from the thread that holds the gate is allowed, but an /// overlapping call from another thread is a contract violation: the notification is dropped and a @@ -18,22 +17,16 @@ namespace ReactiveUI.Primitives.Async; [System.Diagnostics.DebuggerDisplay("WitnessAsync: Disposed = {_disposed}, CallState = {_callState}")] public abstract class WitnessAsync : IObserverAsync, IReentrantAsyncDisposable { - /// Signals disposal to in-flight operations; created on the first request for a dispose token, so a - /// terminal sink that never needs one does not pay for it. + /// Disposal source, allocated only when a disposal token is requested. private CancellationTokenSource? _disposeCts; - /// Disposal latch, set independently of so the lazy path can report - /// post-dispose state with no source in existence. + /// Disposal latch, set independently of so the lazy path can report post-dispose state with no source in existence. private int _disposed; - /// Set when disposal is requested from inside this observer's own notification, telling - /// to skip the in-flight-call wait that would self-join on the caller once its - /// continuation hops threads. + /// Prevents disposal inside a notification from waiting for that same notification. private int _disposeFromNotification; - /// Packed call state: the high 32 bits hold the managed thread ID inside - /// OnNext/OnError/OnCompleted, the low 32 bits the in-flight call depth, updated lock-free through - /// . + /// Atomic call state: owner thread ID in the high 32 bits, active call depth in the low 32 bits. private long _callState; /// Completion source that is set when all in-flight calls finish after disposal has been requested. @@ -54,11 +47,7 @@ protected WitnessAsync() { } - /// - /// Initializes a new instance of the class and links an external cancellation - /// token into its dispose chain. Equivalent to calling the parameterless constructor followed by - /// . - /// + /// Initializes a new instance of the WitnessAsync class linked to external cancellation. /// The external token whose cancellation should trigger this observer's disposal. protected WitnessAsync(CancellationToken externalLink) => LinkExternalCancellation(externalLink); @@ -109,8 +98,7 @@ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) return OnNextAsyncSlow(core, scope); } - /// Routes a non-terminal error to , never propagating a failure back - /// to the producer. + /// Routes a non-terminal error to , never propagating a failure back to the producer. /// The exception that triggered the error handling logic. Cannot be null. /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that completes once the error has been handled. @@ -121,8 +109,7 @@ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellat return default; } - // RouteObserverErrorAsync is an async method, so a synchronous throw is captured into the returned - // ValueTask too and surfaces at the await in OnErrorResumeAsyncSlow; a try/catch here is dead code. + // Callback failures are captured in the returned ValueTask. var core = RouteObserverErrorAsync(error, scope.Token); if (core.IsCompletedSuccessfully) @@ -199,23 +186,43 @@ ValueTask IReentrantAsyncDisposable.DisposeFromNotificationAsync() public ValueTask AssignSourceSubscriptionAsync(IAsyncDisposable? value) => SingleAssignmentDisposableAsync.AssignDisposableAsync(ref _sourceSubscription, value); - /// - /// Makes an upstream observer's disposal dispose this observer too, so an operator chain tears down from the top - /// and its emissions need no linked cancellation source. - /// + /// Makes an upstream observer's disposal dispose this observer too, so an operator chain tears down from the top and its emissions need no linked cancellation source. /// The upstream observer's dispose token. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void LinkUpstreamCancellation(CancellationToken upstream) => LinkExternalCancellation(upstream); + /// Captures the owner thread and active notification count atomically. + /// The packed notification state. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal long ReadCallState() => Volatile.Read(ref _callState); + /// Attempts to enter a notification call, checking for disposal, cancellation, and concurrent access. /// The caller-supplied cancellation token. /// When successful, a providing the effective cancellation token. /// if the call was entered successfully; otherwise, . [DebuggerStepThrough] - internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out LinkedTokenScope scope) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out LinkedTokenScope scope) => + TryEnterOnSomethingCall(Environment.CurrentManagedThreadId, cancellationToken, out scope); + + /// Attempts to enter a notification for the supplied caller thread. + /// The calling thread identifier. + /// Cancellation for the notification. + /// The notification's effective cancellation scope. + /// Whether the caller acquired the notification gate. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool TryEnterOnSomethingCall(int currentThreadId, CancellationToken cancellationToken, out LinkedTokenScope scope) => + TryEnterObservedCallState(currentThreadId, ReadCallState(), cancellationToken, out scope); + + /// Retries notification entry when the captured call state has changed. + /// The calling thread identifier. + /// The captured notification state. + /// Cancellation for the notification. + /// The notification's effective cancellation scope. + /// Whether entry succeeded. + internal bool TryEnterObservedCallState(int currentThreadId, long oldState, CancellationToken cancellationToken, out LinkedTokenScope scope) { - var currentThreadId = Environment.CurrentManagedThreadId; while (true) { if (Volatile.Read(ref _disposed) != 0 || cancellationToken.IsCancellationRequested) @@ -224,13 +231,10 @@ internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out L return false; } - var oldState = Volatile.Read(ref _callState); var oldCount = (int)oldState; var oldThreadId = (int)(oldState >> 32); - // Concurrent-call detection: if another thread is already in-flight, this is a contract - // violation. Reentrant calls from the same thread (a callback that re-enters the observer) - // are legal — only cross-thread overlap fires the exception. + // Same-thread reentrancy is allowed; overlapping calls from different threads violate the contract. if (oldCount > 0 && oldThreadId != currentThreadId) { UnhandledExceptionHandler.ReportUnhandledException(new ConcurrentWitnessCallsException()); @@ -241,8 +245,10 @@ internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out L var newThreadId = oldCount == 0 ? currentThreadId : oldThreadId; var newState = ((long)newThreadId << 32) | (uint)(oldCount + 1); - if (Interlocked.CompareExchange(ref _callState, newState, oldState) != oldState) + var observedState = Interlocked.CompareExchange(ref _callState, newState, oldState); + if (observedState != oldState) { + oldState = observedState; continue; } @@ -255,11 +261,16 @@ internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out L /// when the caller should proceed with disposal itself; /// when a waiting call was signalled instead. [DebuggerStepThrough] - internal bool ExitOnSomethingCall() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool ExitOnSomethingCall() => ExitObservedCallState(ReadCallState()); + + /// Retries notification exit when the captured call state has changed. + /// The captured notification state. + /// Whether the exiting caller owns disposal instead of an existing waiter. + internal bool ExitObservedCallState(long oldState) { while (true) { - var oldState = Volatile.Read(ref _callState); var oldCount = (int)oldState; var oldThreadId = (int)(oldState >> 32); @@ -268,8 +279,10 @@ internal bool ExitOnSomethingCall() var newCount = oldCount - 1; var newState = ((long)(newCount == 0 ? 0 : oldThreadId) << 32) | (uint)newCount; - if (Interlocked.CompareExchange(ref _callState, newState, oldState) != oldState) + var observedState = Interlocked.CompareExchange(ref _callState, newState, oldState); + if (observedState != oldState) { + oldState = observedState; continue; } @@ -287,8 +300,47 @@ internal bool ExitOnSomethingCall() } } - /// Runs , sending anything it throws — and the original error when the - /// call is cancelled — to . + /// Publishes the completion waiter and accounts for a notification that exited before publication. + /// The task completed when the active call count reaches zero. + internal Task PublishCallCompletionWaiter() + { + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + _ = Interlocked.Exchange(ref _allCallsCompletedTcs, tcs); + if ((int)Volatile.Read(ref _callState) == 0) + { + _ = tcs.TrySetResult(null); + } + + return tcs.Task; + } + + /// Publishes a disposal source, releasing a losing allocation and preserving prior disposal. + /// The published dispose source. + internal CancellationTokenSource MaterializeDisposeCts() + { + CancellationTokenSource fresh = new(); + var prior = Interlocked.CompareExchange(ref _disposeCts, fresh, null); + if (prior is not null) + { + fresh.Dispose(); + return prior; + } + + if (Volatile.Read(ref _disposed) != 0) + { + fresh.Cancel(); + } + + return fresh; + } + + /// Disposes when the exiting call owns disposal, or signals an existing waiter. + /// The disposal operation, or a completed task when a waiter owns disposal. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal ValueTask CompleteOrChainDispose() => + ExitOnSomethingCall() ? DisposeAsync() : default; + + /// Runs , sending anything it throws — and the original error when the call is cancelled — to . /// The exception that triggered error handling. /// A cancellation token for the operation. /// A task representing the asynchronous operation. @@ -319,11 +371,7 @@ internal async ValueTask RouteObserverErrorAsync(Exception error, CancellationTo /// A task that completes when the result has been handled. protected abstract ValueTask OnCompletedAsyncCore(Result result); - /// - /// Links an external cancellation token into this observer's dispose chain: cancelling - /// disposes the observer and cancels the token handed to OnNextAsyncCore and - /// OnErrorResumeAsyncCore. An observer holds at most one link, and a second call replaces it. - /// + /// Replaces the external cancellation link that disposes this observer and cancels its notifications. /// The external token whose cancellation should trigger this observer's disposal. [DebuggerStepThrough] protected void LinkExternalCancellation(CancellationToken external) @@ -360,10 +408,8 @@ protected void LinkExternalCancellation(CancellationToken external) _externalLinkedToken = external; } - /// Performs the observer's asynchronous teardown. + /// Claims disposal, waits for active notifications, and releases subscriptions; overrides must call the base implementation. /// A task that represents the asynchronous dispose operation. - /// An override must call the base implementation: it claims disposal for the first caller, waits out a - /// notification in flight on another thread, and releases the cancellation link and source subscription. [DebuggerStepThrough] protected virtual async ValueTask DisposeAsyncCore() { @@ -380,22 +426,7 @@ protected virtual async ValueTask DisposeAsyncCore() && (int)(initialState >> 32) != Environment.CurrentManagedThreadId && Volatile.Read(ref _disposeFromNotification) == 0) { - TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - // Interlocked rather than a release store, so the re-read below cannot be reordered ahead of this - // publish. ExitOnSomethingCall decrements under a full-fence CAS and only then reads this field, so - // with the publish fenced, an Exit that reads a null source has made its decrement visible here and - // one of the two sides always signals. Unfenced, both can miss and the await below never completes. - _ = Interlocked.Exchange(ref _allCallsCompletedTcs, tcs); - - // Exit may have dropped the count to zero before the publish became visible, so self-signal. - var stateAfter = Volatile.Read(ref _callState); - if ((int)stateAfter == 0) - { - _ = tcs.TrySetResult(null); - } - - allOnSomethingCallsCompleted = tcs.Task; + allOnSomethingCallsCompleted = PublishCallCompletionWaiter(); } // Only an existing source needs the cancellation broadcast; a later token request builds one pre-cancelled. @@ -408,24 +439,19 @@ protected virtual async ValueTask DisposeAsyncCore() await CompleteDisposeAfterCancelAsync(allOnSomethingCallsCompleted).ConfigureAwait(false); } - /// Handles a non-terminal error; the sequence may deliver more values afterwards. + /// Handles non-terminal errors; callback failures reach the unhandled-exception handler. /// The exception that triggered the error handling logic. Cannot be null. /// A cancellation token that can be used to cancel the asynchronous error handling operation. /// A task that completes when the error has been handled. - /// An exception thrown from an override is reported to instead of - /// reaching the producer. protected abstract ValueTask OnErrorResumeAsyncCore(Exception error, CancellationToken cancellationToken); - /// Processes the next value in the asynchronous sequence. + /// Delivers the value, forwarding callback failures to error-resume handling and swallowing cancellation. /// The value to be processed. /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that completes when the value has been handled. - /// An exception thrown from an override is fed to , so the sequence - /// survives a failed value; a cancellation is swallowed. protected abstract ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken); - /// Awaits any in-flight notifications, then releases the cancellation link, the dispose source and the - /// source subscription. + /// Awaits any in-flight notifications, then releases the cancellation link, the dispose source and the source subscription. /// Optional gate awaited for in-flight On* calls. /// A task representing the asynchronous teardown. private async ValueTask CompleteDisposeAfterCancelAsync(Task? allOnSomethingCallsCompleted) @@ -452,13 +478,6 @@ private async ValueTask CompleteDisposeAfterCancelAsync(Task? allOnSomethingCall } } - /// Chains into when this exiting call owns disposal, which happens only when a - /// concurrent raised the in-flight gate while the notification was running. - /// The dispose task when this caller owns disposal; otherwise a completed task. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private ValueTask CompleteOrChainDispose() => - ExitOnSomethingCall() ? DisposeAsync() : default; - /// Async continuation for when the core is incomplete. /// The pending core . /// The linked-token scope to release on completion. @@ -546,9 +565,7 @@ private async ValueTask OnCompletedAsyncSlow(ValueTask core, LinkedTokenScope sc } } - /// Builds the for one call, allocating a linked source only when the - /// caller's token is none of , this observer's own dispose token, or the token - /// linked through — each of which cancels the call anyway. + /// Links a distinct caller token with the observer's disposal token. /// The caller-supplied cancellation token. /// A scope whose Token drives the in-flight call's cancellation. private LinkedTokenScope BuildLinkedTokenScope(CancellationToken cancellationToken) @@ -571,32 +588,7 @@ private LinkedTokenScope BuildLinkedTokenScope(CancellationToken cancellationTok private CancellationTokenSource GetOrCreateDisposeCts() => Volatile.Read(ref _disposeCts) ?? MaterializeDisposeCts(); - /// Creates and publishes the dispose source, discarding this instance when another thread wins the - /// publish, and cancelling it when the observer is disposed before any token was requested. - /// The published dispose source. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private CancellationTokenSource MaterializeDisposeCts() - { - CancellationTokenSource fresh = new(); - var prior = Interlocked.CompareExchange(ref _disposeCts, fresh, null); - if (prior is not null) - { - fresh.Dispose(); - return prior; - } - - if (Volatile.Read(ref _disposed) != 0) - { - fresh.Cancel(); - } - - return fresh; - } - - /// - /// Pairs one notification call's effective cancellation token with the linked source backing it, if one was - /// needed, so disposing the scope releases that source. - /// + /// Pairs one notification call's effective cancellation token with the linked source backing it, if one was needed, so disposing the scope releases that source. /// The linked CTS to dispose, or if no allocation was needed. /// The effective cancellation token for the notification call. internal readonly record struct LinkedTokenScope(CancellationTokenSource? Cts, CancellationToken Token) : IDisposable diff --git a/src/ReactiveUI.Primitives.Async.Reactive/Concurrency/SequencerSchedulingExtensions.cs b/src/ReactiveUI.Primitives.Async.Reactive/Concurrency/SequencerSchedulingExtensions.cs index 7b6ebda0..72e75689 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/Concurrency/SequencerSchedulingExtensions.cs +++ b/src/ReactiveUI.Primitives.Async.Reactive/Concurrency/SequencerSchedulingExtensions.cs @@ -10,11 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; -/// -/// Gives the ISequencer scheduling shape the shared Extensions source expects, -/// by forwarding to . Kept internal: it is the seam the leaf -/// uses to invoke schedulers, not public surface. -/// +/// Adapts shared work-item scheduling calls to System.Reactive schedulers. internal static class SequencerSchedulingExtensions { /// Scheduling operators that give an the shared-source sequencer shape. diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net10.0/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net10.0/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net11.0/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net11.0/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net462/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net462/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net472/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net472/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net48/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net48/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net481/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net481/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net8.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net8.0/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net8.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net8.0/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net9.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net9.0/PublicAPI.txt index 3266150e..439ae265 100644 --- a/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net9.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async.Reactive/PublicAPI/net9.0/PublicAPI.txt @@ -12,7 +12,6 @@ namespace ReactiveUI.Primitives.Async.Reactive public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Reactive.Concurrency.IScheduler scheduler) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.Reactive.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.Reactive.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net10.0/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net10.0/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net11.0/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net11.0/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net462/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net462/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net472/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net472/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net48/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net48/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net481/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net481/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net8.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net8.0/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net8.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net8.0/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Async/PublicAPI/net9.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Async/PublicAPI/net9.0/PublicAPI.txt index f18f2361..50e54ad0 100644 --- a/src/ReactiveUI.Primitives.Async/PublicAPI/net9.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Async/PublicAPI/net9.0/PublicAPI.txt @@ -13,7 +13,6 @@ namespace ReactiveUI.Primitives.Async public static ReactiveUI.Primitives.Async.AsyncContext From(ReactiveUI.Primitives.Concurrency.ISequencer scheduler) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.SynchronizationContext synchronizationContext) { } public static ReactiveUI.Primitives.Async.AsyncContext From(System.Threading.Tasks.TaskScheduler taskScheduler) { } - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static ReactiveUI.Primitives.Async.AsyncContext GetCurrent() { } [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] public readonly record struct AsyncContextSwitcherAwaitable : System.IEquatable, System.Runtime.CompilerServices.INotifyCompletion diff --git a/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs b/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs index d5f0fa17..658b70e2 100644 --- a/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs +++ b/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs @@ -48,6 +48,7 @@ protected override bool Post(Action drain) } /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) { DispatcherTimer timer = new(Priority, Dispatcher) { Interval = dueTime }; diff --git a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net10.0/PublicAPI.txt index 70d2fa05..f26b4280 100644 --- a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net10.0/PublicAPI.txt @@ -10,6 +10,7 @@ public sealed class AvaloniaScheduler : ReactiveUI.Primitives.Reactive.Concurren public Avalonia.Threading.Dispatcher Dispatcher { get; } public Avalonia.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } [System.Diagnostics.DebuggerDisplay("CoalescingDispatchScheduler: ReadyCount = {_readyCount}, DrainPosted = {_drainPosted}")] diff --git a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net11.0/PublicAPI.txt index 70d2fa05..f26b4280 100644 --- a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net11.0/PublicAPI.txt @@ -10,6 +10,7 @@ public sealed class AvaloniaScheduler : ReactiveUI.Primitives.Reactive.Concurren public Avalonia.Threading.Dispatcher Dispatcher { get; } public Avalonia.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } [System.Diagnostics.DebuggerDisplay("CoalescingDispatchScheduler: ReadyCount = {_readyCount}, DrainPosted = {_drainPosted}")] diff --git a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net8.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net8.0/PublicAPI.txt index 70d2fa05..f26b4280 100644 --- a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net8.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net8.0/PublicAPI.txt @@ -10,6 +10,7 @@ public sealed class AvaloniaScheduler : ReactiveUI.Primitives.Reactive.Concurren public Avalonia.Threading.Dispatcher Dispatcher { get; } public Avalonia.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } [System.Diagnostics.DebuggerDisplay("CoalescingDispatchScheduler: ReadyCount = {_readyCount}, DrainPosted = {_drainPosted}")] diff --git a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net9.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net9.0/PublicAPI.txt index 70d2fa05..f26b4280 100644 --- a/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net9.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Avalonia.Reactive/PublicAPI/net9.0/PublicAPI.txt @@ -10,6 +10,7 @@ public sealed class AvaloniaScheduler : ReactiveUI.Primitives.Reactive.Concurren public Avalonia.Threading.Dispatcher Dispatcher { get; } public Avalonia.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } [System.Diagnostics.DebuggerDisplay("CoalescingDispatchScheduler: ReadyCount = {_readyCount}, DrainPosted = {_drainPosted}")] diff --git a/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs b/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs index 63cfd3ca..3dd23c31 100644 --- a/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs +++ b/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs @@ -76,6 +76,7 @@ private bool Post(Action drain) /// Runs delayed work on a dispatcher timer bound to the selected dispatcher. /// Work item to execute at the due time. /// Absolute monotonic timestamp at which to execute the item. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void ScheduleDelayed(IWorkItem item, long dueTimestamp) { DispatcherTimer timer = diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs index 679e0776..d614bd16 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs @@ -34,14 +34,8 @@ public IDisposable Subscribe(IObserver observer) return subscription; } - /// Drives a single subscription's pump and coordinates single-owner enumerator disposal. - /// - /// Enumerator disposal is single-owner through an interlocked claim: whichever of the pump's - /// finally and wins the claim disposes the enumerator exactly once. The - /// winner disposes without awaiting , so an enumerator - /// that ignores cancellation is torn down promptly rather than at its own pace. - /// - private sealed class Subscription : IDisposable + /// Pumps values and disposes the enumerator once without waiting for a pending move. + internal sealed class Subscription : IDisposable { /// The downstream observer. private readonly IObserver _observer; @@ -65,7 +59,7 @@ private sealed class Subscription : IDisposable /// The downstream observer. /// The source async enumerable. /// The adapter cancellation token. - public Subscription(IObserver observer, IAsyncEnumerable values, CancellationToken cancellationToken) + internal Subscription(IObserver observer, IAsyncEnumerable values, CancellationToken cancellationToken) { _observer = observer; _values = values; @@ -74,9 +68,6 @@ public Subscription(IObserver observer, IAsyncEnumerable values, Cancellat : new(); } - /// Starts the asynchronous pump. - public void Start() => _ = PumpAsync(); - /// public void Dispose() { @@ -85,15 +76,7 @@ public void Dispose() return; } - try - { - _cts.Cancel(); - } - catch (ObjectDisposedException) - { - // The pump completed and disposed the cancellation source. - } - + _cts.Cancel(); if (TryClaimEnumerator(out var enumerator)) { FireAndForgetDispose(enumerator); @@ -102,46 +85,12 @@ public void Dispose() _cts.Dispose(); } - /// Disposes an enumerator without surfacing the resulting task to the caller. - /// The enumerator to dispose. - private static void FireAndForgetDispose(IAsyncEnumerator enumerator) - { - // Disposal runs detached because IDisposable.Dispose cannot await it; the local method keeps - // the resulting task observed. - _ = ObserveAsync(enumerator); - - static async Task ObserveAsync(IAsyncEnumerator enumerator) - { - try - { - await enumerator.DisposeAsync().ConfigureAwait(false); - } - catch (NotSupportedException) - { - // Some enumerators only support disposal from the enumeration path. - } - } - } - - /// Claims sole ownership of enumerator disposal for the calling path. - /// The enumerator to dispose when the claim succeeds. - /// when the caller won the claim and must dispose the enumerator. - private bool TryClaimEnumerator(out IAsyncEnumerator enumerator) - { - var current = Volatile.Read(ref _enumerator); - if (current is not null && Interlocked.Exchange(ref _enumeratorDisposed, 1) == 0) - { - enumerator = current; - return true; - } - - enumerator = null!; - return false; - } + /// Starts the asynchronous pump. + internal void Start() => _ = PumpAsync(); /// Pumps the async enumerable into the observer. /// The asynchronous pump task. - private async Task PumpAsync() + internal async Task PumpAsync() { try { @@ -149,8 +98,7 @@ private async Task PumpAsync() Volatile.Write(ref _enumerator, enumerator); while (!_cts.IsCancellationRequested && await enumerator.MoveNextAsync().ConfigureAwait(false)) { - // Re-check after the await: disposal may have torn the subscription down while the - // element was in flight, and a buffered value must not reach a stopped observer. + // Cancellation during the move suppresses its buffered value. if (_cts.IsCancellationRequested) { break; @@ -182,6 +130,42 @@ private async Task PumpAsync() Dispose(); } } + + /// Disposes an enumerator without surfacing the resulting task to the caller. + /// The enumerator to dispose. + private static void FireAndForgetDispose(IAsyncEnumerator enumerator) + { + // IDisposable cannot await enumerator disposal. + _ = ObserveAsync(enumerator); + + static async Task ObserveAsync(IAsyncEnumerator enumerator) + { + try + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + catch (NotSupportedException) + { + // Some enumerators only support disposal from the enumeration path. + } + } + } + + /// Claims sole ownership of enumerator disposal for the calling path. + /// The enumerator to dispose when the claim succeeds. + /// when the caller won the claim and must dispose the enumerator. + private bool TryClaimEnumerator(out IAsyncEnumerator enumerator) + { + var current = Volatile.Read(ref _enumerator); + if (current is not null && Interlocked.Exchange(ref _enumeratorDisposed, 1) == 0) + { + enumerator = current; + return true; + } + + enumerator = null!; + return false; + } } } #endif diff --git a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs index 8a0fd093..3d332734 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs @@ -64,8 +64,7 @@ public void OnNext(T value) return; } - // Reset to the skip *before* the hand-off: the observer may throw, and the sink must never be left - // holding an index into a buffer it has released, because the next value would index into null. + // Reset the index before a throwing observer can release the buffer. _buffer = null; _index = 0 - _skip; @@ -120,8 +119,7 @@ public void OnCompleted() /// public void Dispose() { - // Latching here makes the sink terminal on every teardown path, including disposal from Emit when - // the downstream observer throws, so a source that ignores disposal cannot push another value in. + // Reject further values on every teardown path, including observer failure. Volatile.Write(ref _done, 1); SinkSubscription.Dispose(ref _subscription); } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs index 8c80ae1c..da82edf3 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateThrowSignal{T}.cs @@ -37,9 +37,9 @@ public IDisposable Subscribe(IObserver observer) } /// Invokes with the stored error before returning. - /// Never invoked. + /// The unused value callback. /// Invoked with the stored error. - /// Never invoked. + /// The unused completion callback. /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs index bacd6d05..5e8a721d 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmutableEmptySignal{T}.cs @@ -37,8 +37,8 @@ public IDisposable Subscribe(IObserver observer) } /// Invokes before returning. - /// Never invoked. - /// Never invoked. + /// The unused value callback. + /// The unused error callback. /// Invoked before this method returns. /// An empty disposable; the signal has finished by the time this returns. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs index 05968d3d..424c7a90 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs @@ -40,6 +40,9 @@ public SynchronizeWitness(IObserver observer, Lock gate) _gate = gate; } + /// Gets the gate serializing downstream notifications. + internal Lock Gate => _gate; + /// public void OnNext(T value) { diff --git a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs index df70de30..743a54c6 100644 --- a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs +++ b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs @@ -125,10 +125,7 @@ public ScheduledItem( public static bool operator >=(ScheduledItem left, ScheduledItem right) => Comparer>.Default.Compare(left, right) >= 0; - /// - /// Cancels this scheduled work item: work that has not started does not run, and a disposable returned by work - /// that has begun is disposed as soon as it is available. - /// + /// Cancels this scheduled work item: work that has not started does not run, and a disposable returned by work that has begun is disposed as soon as it is available. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Cancel() => Dispose(); diff --git a/src/ReactiveUI.Primitives.Core/Core/CoreCompositeFormats.cs b/src/ReactiveUI.Primitives.Core/Core/CoreCompositeFormats.cs index c72f515d..d775e8ef 100644 --- a/src/ReactiveUI.Primitives.Core/Core/CoreCompositeFormats.cs +++ b/src/ReactiveUI.Primitives.Core/Core/CoreCompositeFormats.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.Core; -/// Holds the parsed composite formats the core value types render with. -/// -/// The formats sit on a non-generic type on purpose. A static field inside is -/// a field of each closed generic, so the format would be parsed once per T; one shared instance -/// serves every one of them. -/// +/// Shares parsed value formats across all closed generic types. internal static class CoreCompositeFormats { /// The format renders with. diff --git a/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs b/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs index 0ed3ec1e..6b545449 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs @@ -14,8 +14,7 @@ namespace ReactiveUI.Primitives.Signals; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class AsyncSignal : IAwaitSignal { - /// Executes the new operation. - /// The result. + /// Serializes observer changes and terminal-state transitions. private readonly Lock _observerLock = new(); /// Stores state for the signal implementation. @@ -239,13 +238,7 @@ public void Dispose() /// The source sequence is empty. public T GetResult() { - if (!IsCompleted) - { - ManualResetEvent completionEvent = new(false); - SubscribeCompletion(() => completionEvent.Set(), false); - _ = completionEvent.WaitOne(); - } - + WaitIfPending(WaitForCompletion); _lastError.Rethrow(); if (!_hasValue) @@ -268,7 +261,30 @@ public void RemoveObserver(IObserver observer) } } - /// Executes the ThrowIfDisposed operation. + /// Invokes the wait operation only while completion is pending. + /// The operation that waits for this signal to complete. + internal void WaitIfPending(Action> wait) + { + if (IsCompleted) + { + return; + } + + wait(this); + } + + /// Blocks the calling thread until the signal completes. + /// The signal supplying the completion notification. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void WaitForCompletion(AsyncSignal signal) + { + // Registration precedes waiting so completion cannot be missed. + using ManualResetEvent completionEvent = new(false); + signal.SubscribeCompletion(() => completionEvent.Set(), false); + _ = completionEvent.WaitOne(); + } + + /// Rejects operations after the signal has been disposed. /// This instance has already been disposed. private void ThrowIfDisposed() { @@ -280,7 +296,7 @@ private void ThrowIfDisposed() throw new ObjectDisposedException(string.Empty); } - /// Executes the SubscribeCompletion operation. + /// Registers a callback for either terminal notification. /// The continuation value. /// The originalContext value. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs b/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs index 6f9703e1..4cd4a90c 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs @@ -30,11 +30,11 @@ public AwaitWitness(Action callback, bool originalContext) _callback = callback; } - /// Executes the OnCompleted operation. + /// Resumes the awaiting continuation on completion. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => InvokeOnOriginalContext(); - /// Executes the OnError operation. + /// Resumes the awaiting continuation when the source fails. /// The error value. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( @@ -46,13 +46,13 @@ public AwaitWitness(Action callback, bool originalContext) + "other; having one call the other would misrepresent an error as a completion.")] public void OnError(Exception error) => InvokeOnOriginalContext(); - /// Executes the OnNext operation. + /// Ignores values; only terminal notifications resume the continuation. /// The value. public void OnNext(T value) { } - /// Executes the InvokeOnOriginalContext operation. + /// Posts the continuation to its captured context, or invokes it directly when none was captured. private void InvokeOnOriginalContext() { if (_context is not null) diff --git a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs index 918ecdfd..0df1f8c7 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Signals; -/// -/// Mutable state and mechanics backing the latest-value (behavior) signals. A single signal instance owns one -/// of these inline (no separate heap object) and forwards its public surface here, so the latest-value logic -/// lives in one place without inheritance or composition between the signal types. -/// +/// Stores the latest value, subscribers, and terminal state of a behavior signal. /// The value type. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", @@ -86,12 +82,7 @@ internal readonly bool TryGetValue(out T? value) } } - /// Notifies all observers about the end of the sequence. - /// - /// The broadcast runs under so it serializes against : a new - /// subscriber is either added before this completes (and is broadcast to here) or after (and replays the - /// terminal state itself), never seeing an out-of-order or duplicated notification. - /// + /// Publishes completion under the subscription gate, preventing duplicate or out-of-order terminal notifications. internal void OnCompleted() { lock (_gate) @@ -129,13 +120,8 @@ internal void OnError(Exception error) } } - /// Notifies all observers about the arrival of the specified value. + /// Updates and broadcasts the latest value under the subscription gate, preserving initial-value ordering. /// The value to send to all observers. - /// - /// The latest-value update and the broadcast happen together under , so they are atomic - /// with respect to ; a new subscriber never observes a live value before the initial - /// value it was promised, and never observes the same value twice. - /// internal void OnNext(T value) { lock (_gate) @@ -165,10 +151,7 @@ internal IDisposable Subscribe(IWitnessRemovable owner, IObserver observer ThrowIfDisposed(); if (!_isStopped) { - // Add and deliver the initial value under the same gate that serializes live broadcast, so - // the new observer is either added before a concurrent OnNext (and sees the initial value - // first, then the live value) or after it (and the live value becomes its initial value). - // It can never observe a newer live value ahead of, or in addition to, its initial value. + // Serialize initial delivery with live broadcast to preserve order and prevent duplicates. _broadcaster.Add(observer); var subscription = new BehaviorWitnessHandler(owner, observer); observer.OnNext(_lastValue!); diff --git a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs index c9373518..ba80e2d5 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs @@ -20,15 +20,7 @@ public sealed class BehaviorSignal : ISignal, IWitnessRemovable /// Gets the current value or throws an exception. /// The initial value passed to the constructor until is called; after which, the last value passed to . - /// - /// is frozen after is called. - /// After is called, always throws the specified exception. - /// An exception is always thrown after is called. - /// - /// Reading is a thread-safe operation, though there's a potential race condition when or are being invoked concurrently. - /// In some cases, it may be necessary for a caller to use external synchronization to avoid race conditions. - /// - /// + /// Completion freezes the value. Failure makes value reads throw the terminal exception, and disposal makes them throw ObjectDisposedException. public T Value => _state.GetValue(); /// Gets a value indicating whether this instance has observers. @@ -50,14 +42,7 @@ public sealed class BehaviorSignal : ISignal, IWitnessRemovable /// Tries to get the current value or throws an exception. /// The initial value passed to the constructor until is called; after which, the last value passed to . /// true if a value is available; false if the subject was disposed. - /// - /// The value returned from is frozen after is called. - /// After is called, always throws the specified exception. - /// - /// Calling is a thread-safe operation, though there's a potential race condition when or are being invoked concurrently. - /// In some cases, it may be necessary for a caller to use external synchronization to avoid race conditions. - /// - /// + /// Completion freezes the value; failure makes reads throw the terminal exception. Each read is synchronized, but a separate read and state check are not atomic. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGetValue(out T? value) => _state.TryGetValue(out value); diff --git a/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs index 264ad079..62f76e23 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs @@ -37,10 +37,7 @@ public struct Broadcaster : IEquatable> /// when the broadcasters reference different observer sets; otherwise, . public static bool operator !=(Broadcaster left, Broadcaster right) => !left.Equals(right); - /// - /// Adds an observer to the broadcaster. The update is a lock-free compare-and-swap, so the - /// broadcaster is self-contained and does not rely on an external lock for correctness. - /// + /// Adds an observer to the broadcaster. The update is a lock-free compare-and-swap, so the broadcaster is self-contained and does not rely on an external lock for correctness. /// Observer to add. public void Add(IObserver observer) { @@ -173,16 +170,7 @@ public override readonly bool Equals(object? obj) => obj is Broadcaster other && Equals(other); /// - /// - /// The hash is taken over the observers the broadcaster is holding, not over the object it happens to - /// hold them in. That keeps it agreeing with — two broadcasters - /// sharing a set share its observers — while saying something about the value rather than about which - /// array the copy-on-write path last allocated. No observers hashes to zero. - /// - /// It moves as observers come and go, which is inherent: equality here is the observer set, and the - /// set is what changes. A broadcaster is compared, never filed in a hash table. - /// - /// + /// The hash reflects the current observers and changes with subscriptions. Do not use a mutable broadcaster as a hash key. [SuppressMessage( "Maintainability", "SST1482:GetHashCode reads mutable state", diff --git a/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs index 772cff90..25412439 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs @@ -91,55 +91,11 @@ public CommandSignal(Func execute, IObservable? canRun) public bool CanRun => Volatile.Read(ref _canRun); /// Gets the lazily allocated fault stream. - private Signal FaultsSignal - { - get - { - var signal = Volatile.Read(ref _faults); - if (signal is not null) - { - return signal; - } - - signal = new(); - var current = Interlocked.CompareExchange(ref _faults, signal, null); - if (current is null) - { - return signal; - } - - signal.Dispose(); - return current; - } - } + private Signal FaultsSignal => Volatile.Read(ref _faults) ?? InstallFaultsSignal(new()); /// Gets the lazily allocated running state stream. - private StateSignal IsRunningSignal - { - get - { - var signal = Volatile.Read(ref _isRunningState); - if (signal is not null) - { - return signal; - } - - signal = new(Volatile.Read(ref _isRunning)); - var current = Interlocked.CompareExchange(ref _isRunningState, signal, null); - if (current is null) - { - // The snapshot above may already be stale: a SetRunning call can run between it and - // the install. Reconcile under the running gate so the just-installed stream cannot - // latch a stale value, and so a concurrent SetRunning cannot lose its update to a - // late seed write here. - ReconcileRunningState(); - return signal; - } - - signal.Dispose(); - return current; - } - } + private StateSignal IsRunningSignal => + Volatile.Read(ref _isRunningState) ?? InstallRunningState(new(Volatile.Read(ref _isRunning))); /// Gets the debugger display text. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] @@ -170,7 +126,7 @@ public CommandExecution ExecuteAsync(CancellationToken cancellationToke } } - /// Executes the Dispose operation. + /// Detaches the execution gate and releases result, fault, and running-state subscriptions. public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) @@ -196,6 +152,79 @@ public IDisposable Subscribe(IObserver observer) return new ResultSubscription(this, observer); } + /// Attempts to append an observer to an unchanged snapshot. + /// The published observer snapshot. + /// The snapshot used to prepare the update. + /// The observer to append. + /// Whether the update was published. + internal static bool TryAddResult(ref object? storage, object? current, IObserver observer) + { + object next; + if (current is IObserver[] many) + { + var copy = new IObserver[many.Length + 1]; + Array.Copy(many, copy, many.Length); + copy[many.Length] = observer; + next = copy; + } + else if (current is IObserver single) + { + next = new[] { single, observer }; + } + else + { + return Interlocked.CompareExchange(ref storage, observer, null) is null; + } + + return ReferenceEquals(Interlocked.CompareExchange(ref storage, next, current), current); + } + + /// Attempts to remove an observer from an unchanged snapshot. + /// The published observer snapshot. + /// The snapshot used to prepare the update. + /// The observer to remove. + /// Whether no retry is required. + internal static bool TryRemoveResult(ref object? storage, object? current, IObserver observer) + { + if (!TryGetRemoveResultNext(current, observer, out var next)) + { + return true; + } + + return ReferenceEquals(Interlocked.CompareExchange(ref storage, next, current), current); + } + + /// Installs a fault stream or releases a candidate that lost installation. + /// The newly allocated candidate. + /// The installed stream. + internal Signal InstallFaultsSignal(Signal signal) + { + var current = Interlocked.CompareExchange(ref _faults, signal, null); + if (current is null) + { + return signal; + } + + signal.Dispose(); + return current; + } + + /// Installs a running stream and reconciles its snapshot with the current execution state. + /// The newly allocated candidate. + /// The installed stream. + internal StateSignal InstallRunningState(StateSignal signal) + { + var current = Interlocked.CompareExchange(ref _isRunningState, signal, null); + if (current is null) + { + ReconcileRunningState(); + return signal; + } + + signal.Dispose(); + return current; + } + /// Gets the observer snapshot that should replace the current snapshot. /// Current observer snapshot. /// Observer to remove. @@ -312,10 +341,7 @@ private void BeginExecution() /// The running state. private void SetRunning(bool value) { - // Set the flag and notify the stream through the same gated path the getter uses. Holding - // the gate across the flag write and the notification keeps the flag and the stream value - // observed together, so the lazy install and a concurrent transition cannot lose each - // other's update. + // Publish the flag and stream value under the installation gate. lock (_runningGate) { _isRunning = value; @@ -370,7 +396,7 @@ private void PublishResult(TResult result) [MethodImpl(MethodImplOptions.AggressiveInlining)] private void PublishFault(Exception error) => Volatile.Read(ref _faults)?.OnNext(error); - /// Executes the ThrowIfDisposed operation. + /// Rejects command operations after disposal. /// The command has been disposed. private void ThrowIfDisposed() { @@ -384,33 +410,13 @@ private void ThrowIfDisposed() /// Adds a result subscriber. /// Observer to add. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void AddResult(IObserver observer) { + // Retry only when another thread replaced the observed snapshot. while (true) { - var current = Volatile.Read(ref _resultObservers); - object next; - if (current is IObserver[] many) - { - var copy = new IObserver[many.Length + 1]; - Array.Copy(many, copy, many.Length); - copy[many.Length] = observer; - next = copy; - } - else if (current is IObserver single) - { - next = new[] { single, observer }; - } - else if (Interlocked.CompareExchange(ref _resultObservers, observer, null) is null) - { - return; - } - else - { - continue; - } - - if (ReferenceEquals(Interlocked.CompareExchange(ref _resultObservers, next, current), current)) + if (TryAddResult(ref _resultObservers, Volatile.Read(ref _resultObservers), observer)) { return; } @@ -423,17 +429,13 @@ private void AddResult(IObserver observer) /// Removes a result subscriber. /// Observer to remove. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void RemoveResult(IObserver observer) { + // Retry only when another thread replaced the observed snapshot. while (true) { - var current = Volatile.Read(ref _resultObservers); - if (!TryGetRemoveResultNext(current, observer, out var next)) - { - return; - } - - if (ReferenceEquals(Interlocked.CompareExchange(ref _resultObservers, next, current), current)) + if (TryRemoveResult(ref _resultObservers, Volatile.Read(ref _resultObservers), observer)) { return; } diff --git a/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs index 4a79fb7a..5beab2a7 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs @@ -19,12 +19,12 @@ public sealed class KeepSignal(IObservable source, Func predicate /// Stores state for the signal implementation. private readonly Func _predicate = predicate; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. + /// Preserves the source's current-thread subscription requirement. /// The result. public bool IsRequiredSubscribeOnCurrentThread() => _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); - /// Executes the Subscribe operation. + /// Subscribes an observer to source values accepted by the predicate. /// The observer value. /// The result. public IDisposable Subscribe(IObserver observer) @@ -48,7 +48,7 @@ private sealed class KeepWitness(IObserver observer, Func predicate) /// Stores state for the signal implementation. private int _stopped; - /// Executes the OnCompleted operation. + /// Forwards completion only while the sink is active. public void OnCompleted() { if (Interlocked.Exchange(ref _stopped, 1) != 0) @@ -59,7 +59,7 @@ public void OnCompleted() _observer.OnCompleted(); } - /// Executes the OnError operation. + /// Stops the sink and forwards its first error. /// The error value. public void OnError(Exception error) { @@ -71,7 +71,7 @@ public void OnError(Exception error) _observer.OnError(error); } - /// Executes the OnNext operation. + /// Filters active values and turns predicate failures into terminal errors. /// The value. public void OnNext(T value) { diff --git a/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs b/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs index 356c5258..795ebb02 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Signals; -/// -/// Filters source values using a caller-supplied state value, without allocating a per-value closure: the state is -/// stored on the sink and passed to the predicate for each element. -/// +/// Filters source values using a caller-supplied state value, without allocating a per-value closure: the state is stored on the sink and passed to the predicate for each element. /// The value type. /// The state type passed to the predicate. /// The source sequence. diff --git a/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs index 56dcd610..35e72aff 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs @@ -20,12 +20,12 @@ public sealed class MapSignal(IObservable source, Fun /// Stores state for the signal implementation. private readonly Func _selector = selector; - /// Executes the IsRequiredSubscribeOnCurrentThread operation. + /// Preserves the source's current-thread subscription requirement. /// The result. public bool IsRequiredSubscribeOnCurrentThread() => _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); - /// Executes the Subscribe operation. + /// Subscribes an observer to the selected source values. /// The observer value. /// The result. public IDisposable Subscribe(IObserver observer) @@ -49,7 +49,7 @@ private sealed class MapWitness(IObserver observer, FuncStores state for the signal implementation; non-zero once the sink has terminated. private int _stopped; - /// Executes the OnCompleted operation. + /// Forwards completion only while the sink is active. public void OnCompleted() { if (Interlocked.Exchange(ref _stopped, 1) != 0) @@ -60,7 +60,7 @@ public void OnCompleted() _observer.OnCompleted(); } - /// Executes the OnError operation. + /// Stops the sink and forwards its first error. /// The error value. public void OnError(Exception error) { @@ -72,7 +72,7 @@ public void OnError(Exception error) _observer.OnError(error); } - /// Executes the OnNext operation. + /// Projects active values and turns selector failures into terminal errors. /// The value. public void OnNext(TSource value) { diff --git a/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs b/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs index cc8c05dd..9cc1c700 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs @@ -11,8 +11,7 @@ namespace ReactiveUI.Primitives.Signals; [System.Diagnostics.DebuggerDisplay("ObserverHandler: Subject = {_subject}, Observer = {_observer}")] public sealed class ObserverHandler(AsyncSignal subject, IObserver observer) : IDisposable { - /// Executes the new operation. - /// The result. + /// Serializes detachment from the signal. private readonly Lock _gate = new(); /// Stores state for the signal implementation. @@ -22,7 +21,7 @@ public sealed class ObserverHandler(AsyncSignal subject, IObserver obse /// Stores state for the signal implementation. private IObserver? _observer = observer; - /// Executes the Dispose operation. + /// Detaches the observer once and releases references to both endpoints. public void Dispose() { lock (_gate) diff --git a/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs index ba74bbfa..a9063ac3 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs @@ -61,10 +61,7 @@ public TResult Value /// Gets the stream of current and subsequent values. public IObservable Changed => this; - /// - /// Creates a projected read-only state and subscribes it to the source after construction, so the - /// instance is never exposed to the source while partially constructed. - /// + /// Creates a projected read-only state and subscribes it to the source after construction, so the instance is never exposed to the source while partially constructed. /// The source state signal. /// The projection applied to each source value. /// The fully-initialized projected read-only state. diff --git a/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs index 72628e48..8fa1fd3b 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs @@ -45,7 +45,7 @@ public ReadOnlyState(IObservable source, T initialValue) [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => _inner.Subscribe(observer); - /// Executes the Dispose operation. + /// Stops mirroring the source and disposes the cached state. public void Dispose() { _subscription.Dispose(); diff --git a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs index 4edde548..b6dffb44 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs @@ -25,23 +25,13 @@ public class Signal : ISignal /// Published in place of the observers once the signal has been disposed. private static readonly object DisposedMarker = new(); - /// - /// Guards observer-set and terminal-state mutations. Dispatch does not take it: subscribe, remove, and - /// the terminal transitions each publish one new value to , and they mutate - /// reusable array slots in place rather than copying, so subscribe/unsubscribe churn does not allocate a - /// new array per change. - /// + /// Serializes observer-set and terminal-state mutations; dispatch reads the published target without this gate. private readonly Lock _observerLock = new(); /// Stores state for the signal implementation. private Exception? _exception; - /// - /// The dispatch target, and the only field reads: while - /// nobody is subscribed, the subscription itself for exactly one subscriber, the slot array for more, or - /// one of the terminal markers. Every shape change publishes a single new value here, so a dispatch sees - /// either the whole change or none of it. That is what lets the emit path run without taking the gate. - /// + /// Atomically published dispatch target: empty, one subscription, a slot array, or a terminal marker. private object? _observers; /// The reusable slot array backing the multi-subscriber shape, kept across an empty period. @@ -137,11 +127,7 @@ public void OnError(Exception error) /// Called when [next]. /// The value. - /// - /// One volatile read of decides the whole dispatch, so emitting never takes the - /// observer gate. A terminal transition or a disposal publishes a marker to that same field, which is how - /// a stopped signal stays silent and a disposed one still throws without a lock on the emit path. - /// + /// Emission reads the published observer state without locking. Terminal signals ignore subsequent values; disposed signals throw. public void OnNext(T value) { var observers = Volatile.Read(ref _observers); @@ -207,7 +193,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Executes the SubscribeAction operation. + /// Registers a value callback, or rethrows the stored error if the signal has already failed. /// The onNext value. /// The result. public IDisposable SubscribeAction(Action onNext) @@ -263,8 +249,7 @@ protected virtual void Dispose(bool disposing) { _exception = null; - // Set before the marker is published: the marker goes out with a release write, so a dispatch - // that acquires it is guaranteed to see the disposed flag its trailing check reads. + // The release write publishes the disposed flag with the terminal marker. _isDisposed = true; observers = ClearObserversLocked(DisposedMarker); } @@ -274,13 +259,9 @@ protected virtual void Dispose(bool disposing) /// Creates the exception every use-after-disposal path throws. /// The exception to throw. - /// - /// Returned rather than thrown so each caller ends in throw. The guard-clause shape the analyzers - /// require would otherwise leave every one of those methods with an epilogue nothing can reach. - /// private static ObjectDisposedException Disposed() => new(string.Empty); - /// Executes the Completed operation. + /// Forwards completion to each subscription in the captured observer snapshot. /// The observer shape captured while the signal was still running. private static void Completed(object? observers) { @@ -301,7 +282,7 @@ private static void Completed(object? observers) } } - /// Executes the Error operation. + /// Forwards an error to each subscription in the captured observer snapshot. /// The observer shape captured while the signal was still running. /// The exception value. private static void Error(object? observers, Exception exception) @@ -323,7 +304,7 @@ private static void Error(object? observers, Exception exception) } } - /// Executes the HasActionSubscribers operation. + /// Checks whether the captured snapshot contains any value-only callback subscriptions. /// The observer shape captured while the signal was still running. /// The result. private static bool HasActionSubscribers(object? observers) @@ -349,7 +330,7 @@ private static bool HasActionSubscribers(object? observers) return false; } - /// Executes the DisposeSubscriptions operation. + /// Detaches each subscription in the captured observer snapshot. /// The observer shape captured before disposal. private static void DisposeSubscriptions(object? observers) { @@ -370,7 +351,7 @@ private static void DisposeSubscriptions(object? observers) } } - /// Executes the DispatchSubscriptions operation. + /// Forwards a value to each occupied subscription slot. /// The subscription snapshot, which the observer field only ever holds non-null. /// The value. private static void DispatchSubscriptions(SignalSubscription?[] subscriptions, T value) @@ -401,7 +382,7 @@ private void DispatchToSlots(SignalSubscription?[] subscriptions, T value) throw Disposed(); } - /// Executes the ThrowIfDisposed operation. + /// Rejects operations after the signal has been disposed. private void ThrowIfDisposed() { if (!IsDisposed) @@ -484,7 +465,7 @@ private void AddSubscriptionLocked(SignalSubscription subscription) return observers; } - /// Executes the Remove operation. + /// Removes a subscription while holding the observer lock. /// The subscription value. private void Remove(SignalSubscription subscription) { @@ -504,14 +485,9 @@ private void Remove(SignalSubscription subscription) } } - /// Removes an array-backed subscription, keeping the array for the next subscriber. + /// Removes a subscription from its owning slot array. /// The active slot array. /// The subscription value. - /// - /// A subscription disposes itself once and only ever sits in the array it was added to, so the search - /// always finds it. The index guard is what keeps that assumption from corrupting the slot array if it - /// ever stops holding. - /// private void RemoveFromSlotsLocked(SignalSubscription?[] slots, SignalSubscription subscription) { var index = Array.IndexOf(slots, subscription); @@ -577,8 +553,6 @@ public SignalSubscription(Signal subject, Action onNext) /// The value. public void OnNext(T value) { - // Branch on a null check of the typed fields rather than an `is Action` test plus cast: this runs - // once per observer per value on the multicast dispatch hot path, where that overhead is measurable. var observer = _observer; if (observer is not null) { @@ -598,7 +572,7 @@ public void OnNext(T value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => _observer?.OnCompleted(); - /// Executes the Dispose operation. + /// Detaches this subscription once. public void Dispose() { var subject = Interlocked.Exchange(ref _subject, null); diff --git a/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs b/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs index 6a1f3786..77456a7c 100644 --- a/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs +++ b/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs @@ -38,10 +38,7 @@ public void Rethrow() /// Signals sequence to subscribe to. extension(IObservable source) { - /// - /// Subscribes to the Signals sequence without specifying any handlers. - /// This method can be used to evaluate the Signals sequence for its side-effects only. - /// + /// Subscribes to the Signals sequence without specifying any handlers. This method can be used to evaluate the Signals sequence for its side-effects only. /// object used to unsubscribe from the Signals sequence. /// is null. public IDisposable Subscribe() diff --git a/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs b/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs index dd3a3bce..eb6c9e1c 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs @@ -68,8 +68,7 @@ public IDisposable Subscribe(IObserver observer) return subscription; } - /// Clears the lazy enumerator. Caller must hold on the production - /// paths; exposed to internal tests that exercise the idempotent-second-call branch. + /// Clears the lazy enumerator. Caller must hold on the production paths; exposed to internal tests that exercise the idempotent-second-call branch. internal void ClearRator() { _rator?.Dispose(); @@ -92,7 +91,7 @@ internal void PullNextTask(IObserver observer) => "PSH1315:A blocking wait on an awaitable that may not be done", Justification = "Task is guaranteed complete at this call site (IsFaulted/IsCanceled were both false above); reading .Result drives the synchronous IObserver contract without blocking.")] - private void ProcessTaskCompletion(Subscription subscription, Task completed) + internal void ProcessTaskCompletion(Subscription subscription, Task completed) { lock (_gate) { @@ -152,9 +151,7 @@ private void PullNextTask(Subscription subscription) _outstanding++; - // The continuation passes the Subscription as state — already a reference type, so - // no per-task ValueTuple boxing is needed. The static lambda preserves zero closure - // capture. + // The subscription carries continuation state without boxing or closure capture. _rator.Current?.ContinueWith( static (ant, state) => { @@ -168,9 +165,7 @@ private void PullNextTask(Subscription subscription) } } - /// Per-subscription handle: holds the observer reference and a disposal latch. - /// Replaces the previous ActionDisposable(() => Disposed = true) pattern with a - /// dedicated class — no closure object per subscribe. + /// Holds the observer and prevents notification delivery after disposal. /// The owning limiter. /// The downstream observer. internal sealed class Subscription(ConcurrencyLimiter limiter, IObserver observer) : IDisposable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs b/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs index 42b258ac..2782b64e 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs @@ -100,10 +100,7 @@ protected virtual async void Dispose(bool disposing) private static void SignalPhaseSync(object? state) => ((Continuation)state!)._phaseSync.SignalAndWait(CancellationToken.None); - /// Schedules on the default task scheduler. Hoisted - /// out of the and call sites because cobertura - /// tags the multi-argument Task.Factory.StartNew(...) call as a branch line — the - /// per-call overload-resolution metadata is collapsed here so it counts once. + /// Schedules an action on the default task scheduler. /// The task representing the scheduled signal work. [MethodImpl(MethodImplOptions.AggressiveInlining)] private Task ScheduleSignalPhase() => diff --git a/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs b/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs index 8ca39789..849d22c4 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs @@ -7,15 +7,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Thread-safe subject that holds the most-recently-emitted value, replays it to new subscribers, -/// and broadcasts subsequent emissions. Per-emission hot path: -/// -/// Lock taken only to read the current observer state and update the cached value. -/// Single-observer state lives in a dedicated field — no array allocated for the common case. -/// Multi-observer state uses a copy-on-write IObserver{T}[] snapshot iterated outside the lock. -/// -/// +/// Stores the latest value, replays it to new subscribers, and broadcasts updates outside the state lock. /// The element type. [System.Diagnostics.DebuggerDisplay("CurrentValueSubject: Value = {_value}, Completed = {_completed}, Disposed = {_disposed}")] public sealed class CurrentValueSubject : IObservable, IObserver, IDisposable @@ -256,11 +248,7 @@ private void Unsubscribe(IObserver observer) return; } - // Subscription.Dispose's Interlocked guard means a given observer reaches Unsubscribe - // at most once, and Subscribe ensures that observer is present in _observers before - // returning. OnError / OnCompleted / Dispose nullify _observers atomically, which the - // is-null check above already short-circuits — so when we get here, IndexOf finds the - // observer by construction. + // A subscription removes its registered observer at most once. var index = Array.IndexOf(existing, observer); if (existing.Length == 2) @@ -286,10 +274,7 @@ private void Unsubscribe(IObserver observer) } } - /// Per-subscription handle that detaches the observer on dispose. Idempotency is - /// enforced via on — - /// the second dispose sees and returns. Eliminates the previous - /// dedicated _disposed int and shaves a field off every subscription. + /// Atomically clears its observer reference to detach exactly once. /// The owning subject. /// The observer to detach. private sealed class Subscription(CurrentValueSubject parent, IObserver observer) : IDisposable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs index 161c4021..876b034d 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs @@ -4,14 +4,7 @@ namespace ReactiveUI.Primitives.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 RxVoid-tested -/// against this class. -/// +/// Claims one-time transitions and tolerates cancellation of disposed token sources. internal static class ConcurrencyRaceHelpers { /// @@ -29,13 +22,7 @@ internal static class ConcurrencyRaceHelpers internal 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. - /// + /// Cancels the source asynchronously, tolerating concurrent disposal. /// The cancellation token source to cancel. /// /// if the cancellation completed; if the diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs index a7514283..f82aca30 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs @@ -7,21 +7,10 @@ namespace ReactiveUI.Primitives.Extensions.Internal; -/// -/// Shared subscription loop for sync operators that fan an indexed callback set across an N-source -/// list (e.g. BooleanReduceObservable, MinMaxObservable). Each call site previously -/// hand-rolled the same loop — index-capture, -/// triple, aggregate — so centralising it here keeps the per-emission -/// closure shape consistent and the duplication off Sonar's CPD radar. -/// +/// Subscribes indexed callbacks to each source and aggregates the subscription handles. internal static class IndexedSubscribeHelper { - /// - /// Subscribes the supplied callbacks to every source in , threading - /// each source's positional index through to the and - /// hooks. The returned disposable disposes every per-source - /// subscription on dispose. - /// + /// Subscribes callbacks carrying each source's index and returns a handle that disposes all subscriptions. /// The element type of the source observables. /// The source observables, indexed 0..N-1. /// Per-source OnNext hook: (index, value). diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs index 72c5ad82..921ca609 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs @@ -14,12 +14,7 @@ internal static class ObservableSubscribeExtensions /// The source observable. extension(IObservable source) { - /// - /// Subscribes using delegate callbacks for OnNext / OnError / OnCompleted. Unique name to - /// avoid the System.Reactive Subscribe(onNext, onError, onCompleted) ambiguity; the - /// delegates are wrapped by the core sink rather than a - /// duplicated observer. - /// + /// Subscribes delegate callbacks through the core sink; the distinct name avoids ambiguity with System.Reactive extensions. /// Per-value callback. /// Error callback. /// Completion callback. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs index 3b84df37..bb3123fc 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs @@ -4,12 +4,7 @@ namespace ReactiveUI.Primitives.Extensions.Internal; -/// -/// Shared synchronous reduce-sink state used by BooleanReduceObservable (AllTrue / AllFalse) -/// and MinMaxObservable (Max / Min). Each per-operator sink composes one instance (has-a, not -/// is-a) and adds only its operator-specific OnNext reduce step; the boilerplate gate, value cache, -/// completion bookkeeping, OnError, and OnCompleted bodies all live here in one place. -/// +/// Stores synchronized reduction state and coordinates terminal notifications. /// The source element type (must be a struct so TIn? doubles as the /// "value seen yet?" Optional). /// The downstream element type the operator emits after reducing. @@ -95,10 +90,7 @@ internal void HandleError(Exception error) } } - /// - /// Records completion of the source at . The combined sequence terminates - /// once every source has completed OR a source completes without ever having emitted a value. - /// + /// Records source completion, terminating when all sources complete or one completes without a value. /// The 0-based source index that just completed. internal void HandleCompleted(int index) { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs index 2b1a05b2..eb36b262 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs @@ -88,11 +88,7 @@ private static IReadOnlyList> MaterializeSources(IEnumerable - /// Sink that holds the latest value per source and reduces them against . - /// Composes for the shared gate / value cache / OnError / - /// OnCompleted plumbing so this class carries only the per-operator reduce step. - /// + /// Compares each source's latest value with the target using shared value and terminal state. /// The downstream observer. /// The number of sources. /// The value every source must hold for emit to be true. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs index 8bf50b12..137c4edb 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Catches the configured exception type, emits a fallback built from the exception, and completes. -/// Other exception types propagate downstream. -/// +/// Catches the configured exception type, emits a fallback built from the exception, and completes. Other exception types propagate downstream. /// Element type. /// Exception type to catch. /// Upstream source. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs index 1e996a4c..93d4ba87 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs @@ -22,11 +22,7 @@ public IDisposable Subscribe(IObserver observer) return new DoOnDisposeSubscription(source.Subscribe(observer), disposeAction); } - /// - /// Per-subscribe disposal handle that forwards to the source - /// subscription and then to the caller-supplied action. Dedicated class instead of the - /// previous ActionDisposable(() => …) form so no closure is allocated per subscribe. - /// + /// Disposes the upstream subscription, then invokes the action exactly once. /// The upstream subscription disposed before the action fires. /// The action executed once after the upstream is disposed. private sealed class DoOnDisposeSubscription(IDisposable subscription, Action disposeAction) : IDisposable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs index ea38cf77..8f18f0ee 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs @@ -1,22 +1,16 @@ // Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. - using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Disposables; namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Operator that drops source elements while an asynchronous action is in progress. -/// Replaces the closure-based implementation in ReactiveExtensions.DropIfBusy. -/// -/// The element type. -/// The source observable. -/// The asynchronous action to execute for each forwarded element. -public sealed class DropIfBusyObservable( - IObservable source, - Func asyncAction) : IObservable +/// Operator that drops source elements while an asynchronous action is in progress. Replaces the closure-based implementation in ReactiveExtensions.DropIfBusy. +/// The element type. +/// The source observable. +/// The asynchronous action to execute for each forwarded element. +public sealed class DropIfBusyObservable(IObservable source, Func asyncAction) : IObservable { /// public IDisposable Subscribe(IObserver observer) @@ -29,12 +23,10 @@ public IDisposable Subscribe(IObserver observer) return new DisposableBag(sub, sink); } - /// Sink that manages the busy state and executes the async action. - /// The downstream observer. - /// The async action to run. - private sealed class DropIfBusySink( - IObserver downstream, - Func asyncAction) : IObserver, IDisposable + /// Processes source values and owns the subscription state. + /// The downstream observer. + /// The asynchronous operation. + internal sealed class DropIfBusySink(IObserver downstream, Func asyncAction) : IObserver, IDisposable { /// 0 = idle, 1 = busy. private int _isBusy; @@ -43,21 +35,8 @@ private sealed class DropIfBusySink( private int _done; /// - public void OnNext(T value) - { - if (Volatile.Read(ref _done) != 0) - { - return; - } - - // If we can transition from 0 to 1, we handle this value. - if (Interlocked.CompareExchange(ref _isBusy, 1, 0) != 0) - { - return; - } - - _ = ProcessAsync(value); - } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public void OnNext(T value) => _ = OnNextAsync(value); /// public void OnError(Exception error) @@ -81,8 +60,24 @@ public void OnCompleted() [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => Volatile.Write(ref _done, 1); + /// Processes a value and returns its active operation. + /// The source value. + /// The processing task, or a completed task when no work starts. + internal Task OnNextAsync(T value) + { + if (Volatile.Read(ref _done) != 0) + { + return Task.CompletedTask; + } + + // If we can transition from 0 to 1, we handle this value. + return Interlocked.CompareExchange(ref _isBusy, 1, 0) != 0 + ? Task.CompletedTask + : ProcessAsync(value); + } + /// Executes the async action and manages the busy state transition. - /// The value to process. + /// The value to process. /// A task representing the async operation. private async Task ProcessAsync(T value) { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs index 827ed604..c6af7913 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Operator that filters string elements using a . Replaces the closure-based implementation in ReactiveExtensions.Filter. +/// Filters strings using a regular expression and forwards matching errors to the observer. /// The source observable emitting strings. /// The regex to use for filtering. public sealed class FilterRegexObservable( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs index fc5ea674..eaac15fc 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs @@ -8,12 +8,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// -/// Walks a list of candidate keys sequentially, projects each into a one-shot -/// , transforms the raw value into -/// , and emits the first transformed value that -/// satisfies a predicate. Errors from individual projections are swallowed (the -/// candidate is skipped and the next one is tried). If no candidate matches, -/// completes with a single emission of . +/// Projects candidates sequentially and emits the first transformed value satisfying the predicate. +/// Projection errors skip the candidate. If none matches, emits the fallback value and completes. /// /// The type of candidate keys. /// The element type emitted by the projected observable. @@ -23,13 +19,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// Synchronous transform applied to each raw value to produce the result. /// Returns when a transformed value is a match. /// Value emitted when no candidate matches. -/// -/// Subscribe attempts a synchronous fast-path first: each candidate's -/// projection is subscribed and, if it completes inline, the transform + predicate -/// run on the calling thread with zero additional allocations. Only when a -/// projection completes asynchronously does the method allocate an -/// to track state across callbacks. -/// +/// Synchronous projections run on the caller's thread. Asynchronous projections retain their state until callbacks complete. public sealed class FirstMatchFromCandidatesObservable( IReadOnlyList candidates, Func> project, @@ -61,10 +51,7 @@ public IDisposable Subscribe(IObserver observer) /// The subscription disposable. internal IDisposable TrySyncLoop(IObserver observer) { - // Reuse the SyncProbe across subscribes on the current thread — it carries no - // per-call state once Reset, so per-cycle allocation drops to zero on the fast path. - // Race-free because the field is [ThreadStatic]; only one TrySyncLoop call can be - // active per thread. + // The thread-local probe is reset between subscriptions. var probe = SyncProbe.RentForCurrentThread(); for (var i = 0; i < candidates.Count; i++) @@ -125,8 +112,7 @@ internal IDisposable TrySyncLoop(IObserver observer) [System.Diagnostics.DebuggerDisplay("SyncProbe: Completed = {Completed}, HasValue = {HasValue}, Value = {Value}")] public sealed class SyncProbe : IObserver { - /// Per-thread cached instance; rented on entry to TrySyncLoop and returned - /// on exit. Eliminates the per-subscribe allocation on the fast path. + /// Per-thread cached instance; rented on entry to TrySyncLoop and returned on exit. Eliminates the per-subscribe allocation on the fast path. [ThreadStatic] private static SyncProbe? _cached; @@ -188,10 +174,7 @@ internal void Reset() } } - /// - /// Heap-allocated observer used when a projection does not complete synchronously. - /// Walks the remaining candidates via async callbacks. - /// + /// Heap-allocated observer used when a projection does not complete synchronously. Walks the remaining candidates via async callbacks. /// The downstream observer. /// The candidate list. /// The projection delegate. @@ -346,12 +329,7 @@ internal void TryNext() downstream.OnCompleted(); } - /// - /// Forwarding that records whether a terminal notification - /// (OnError / OnCompleted) arrived synchronously during the Subscribe - /// call. Replaces the prior _syncCompleted field on so the - /// flag is per-iteration state rather than instance state. - /// + /// Forwards notifications and records synchronous termination for one candidate subscription. /// The wrapped sink that receives forwarded notifications. private sealed class CompletionFlagWitness(IObserver inner) : IObserver { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs index 0bdd4c0a..d5570e6c 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs @@ -102,9 +102,7 @@ public void Dispose() lock (_parent._gate) { - // Invariant: a Subscription whose Interlocked.Exchange just transitioned _disposed - // from 0 to 1 was created by Subscribe under the same lock, which sets _sink - // before returning — so _sink is non-null here by construction. + // Subscribe assigns the sink under this lock before returning the handle. _parent._sink!.Remove(_observer, _side); _parent._subscriptionCount--; if (_parent._subscriptionCount == 0) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs index f278aeb4..7db5bebc 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs @@ -6,14 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Fused replacement for -/// Observable.FromEventPattern(...).Where(name match).Select(getter).StartWith(getter). -/// Subscribes directly to , holds the -/// compiled getter once at construction (the original chain compiled it twice per -/// subscription), and emits the current value on subscribe followed by the getter -/// result for each matching property change — all through a single sink. -/// +/// Emits the current property value on subscription, then invokes the shared getter for matching property changes. /// The owning type that raises . /// The property element type. /// The owning instance. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs index c40010db..87381ba5 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs @@ -5,10 +5,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// -/// Emits a stored initial value to every new subscriber, then forwards subsequent emissions from the -/// shared source. Each subscriber gets its own independent subscription to the source; the per-subscriber -/// replay is the fixed initialValue supplied at construction (matching the legacy BehaviorSubject -/// semantics — late subscribers do NOT see the latest value emitted to earlier subscribers). +/// Emits the fixed initial value to each subscriber, then subscribes independently to the source. +/// Late subscribers receive that initial value, not a cached source emission. /// /// The element type of the source observable. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs index 12ac76be..ed6d5d89 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs @@ -1,21 +1,17 @@ // 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.Primitives.Disposables; namespace ReactiveUI.Primitives.Extensions.Operators; /// Projects each element to an asynchronous operation with limited concurrency. -/// The type of elements in the source sequence. -/// The type of the result of the asynchronous operation. -/// The source observable. -/// The asynchronous projection function. -/// The maximum number of concurrent operations. -public sealed class SelectAsyncConcurrentObservable( - IObservable source, - Func> selector, - int maxConcurrency) : IObservable +/// The type of elements in the source sequence. +/// The type of the result of the asynchronous operation. +/// The source observable. +/// The asynchronous projection function. +/// The maximum number of concurrent operations. +public sealed class SelectAsyncConcurrentObservable(IObservable source, Func> selector, int maxConcurrency) : IObservable { /// public IDisposable Subscribe(IObserver observer) @@ -23,20 +19,16 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(source); InvalidOperationExceptionHelper.ThrowIfNull(selector); ArgumentExceptionHelper.ThrowIfNull(observer); - SelectAsyncConcurrentSink sink = new(observer, selector, maxConcurrency); var sub = source.Subscribe(sink); return new DisposableBag(sub, sink); } - /// Sink that manages concurrent async projection. - /// The downstream observer. - /// The async selector. - /// The maximum concurrency. - private sealed class SelectAsyncConcurrentSink( - IObserver downstream, - Func> selector, - int maxConcurrency) : IObserver, IDisposable + /// Processes source values and owns the subscription state. + /// The downstream observer. + /// The asynchronous operation. + /// The maximum concurrency. + internal sealed class SelectAsyncConcurrentSink(IObserver downstream, Func> selector, int maxConcurrency) : IObserver, IDisposable { /// The gate for state access. private readonly Lock _gate = new(); @@ -54,19 +46,8 @@ private sealed class SelectAsyncConcurrentSink( private bool _disposed; /// - public void OnNext(TSource value) - { - lock (_gate) - { - if (_done || _disposed) - { - return; - } - - _queue.Enqueue(value); - TryProcessNext(); - } - } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public void OnNext(TSource value) => _ = OnNextAsync(value); /// public void OnError(Exception error) @@ -110,19 +91,43 @@ public void Dispose() } } + /// Processes a value and returns its active operation. + /// The source value. + /// The processing task, or a completed task when no work starts. + internal Task OnNextAsync(TSource value) + { + Task processing; + lock (_gate) + { + if (_done || _disposed) + { + return Task.CompletedTask; + } + + _queue.Enqueue(value); + processing = TryProcessNext(); + } + + return processing; + } + /// Attempts to process the next value in the queue. - private void TryProcessNext() + /// The last operation started, or a completed task when the queue cannot advance. + private Task TryProcessNext() { + var processing = Task.CompletedTask; while (_running < maxConcurrency && _queue.Count > 0) { var value = _queue.Dequeue(); _running++; - _ = ProcessAsync(value); + processing = ProcessAsync(value); } + + return processing; } /// Processes the async operation. - /// The value to project. + /// The value to project. /// A task representing the operation. private async Task ProcessAsync(TSource value) { @@ -161,7 +166,7 @@ private async Task ProcessAsync(TSource value) } else { - TryProcessNext(); + _ = TryProcessNext(); } } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs index b49a7378..dbe6a704 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs @@ -1,19 +1,16 @@ // Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. - using ReactiveUI.Primitives.Disposables; namespace ReactiveUI.Primitives.Extensions.Operators; /// Projects each element to an asynchronous operation, preserving order and handling sequential execution. -/// The type of elements in the source sequence. -/// The type of the result of the asynchronous operation. -/// The source observable. -/// The asynchronous projection function. -public sealed class SelectAsyncSequentialObservable( - IObservable source, - Func> selector) : IObservable +/// The type of elements in the source sequence. +/// The type of the result of the asynchronous operation. +/// The source observable. +/// The asynchronous projection function. +public sealed class SelectAsyncSequentialObservable(IObservable source, Func> selector) : IObservable { /// public IDisposable Subscribe(IObserver observer) @@ -21,18 +18,15 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(source); InvalidOperationExceptionHelper.ThrowIfNull(selector); ArgumentExceptionHelper.ThrowIfNull(observer); - SelectAsyncSequentialSink sink = new(observer, selector); var sub = source.Subscribe(sink); return new DisposableBag(sub, sink); } - /// Sink that manages sequential async projection. - /// The downstream observer. - /// The async selector. - private sealed class SelectAsyncSequentialSink( - IObserver downstream, - Func> selector) : IObserver, IDisposable + /// Processes source values and owns the subscription state. + /// The downstream observer. + /// The asynchronous operation. + internal sealed class SelectAsyncSequentialSink(IObserver downstream, Func> selector) : IObserver, IDisposable { /// The gate for state access. private readonly Lock _gate = new(); @@ -50,23 +44,8 @@ private sealed class SelectAsyncSequentialSink( private bool _disposed; /// - public void OnNext(TSource value) - { - lock (_gate) - { - if (_done || _disposed) - { - return; - } - - _queue.Enqueue(value); - if (!_isProcessing) - { - _isProcessing = true; - _ = ProcessNextAsync(); - } - } - } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public void OnNext(TSource value) => _ = OnNextAsync(value); /// public void OnError(Exception error) @@ -110,6 +89,30 @@ public void Dispose() } } + /// Processes a value and returns its active operation. + /// The source value. + /// The processing task, or a completed task when no work starts. + internal Task OnNextAsync(TSource value) + { + var processing = Task.CompletedTask; + lock (_gate) + { + if (_done || _disposed) + { + return Task.CompletedTask; + } + + _queue.Enqueue(value); + if (!_isProcessing) + { + _isProcessing = true; + processing = ProcessNextAsync(); + } + } + + return processing; + } + /// Processes the next value in the queue. /// A task representing the operation. private async Task ProcessNextAsync() diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs index 78c56b55..13e4b11d 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs @@ -28,10 +28,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new SelectConstantWitness(observer, constant)); } - /// - /// Forwarding observer that replaces every value with - /// the stored constant. Error and completion signals pass through unchanged. - /// + /// Forwarding observer that replaces every value with the stored constant. Error and completion signals pass through unchanged. /// The downstream observer. /// The constant value to emit. private sealed class SelectConstantWitness(IObserver downstream, TResult constant) : IObserver diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs index 7385798a..3b43bb5c 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs @@ -1,19 +1,16 @@ // Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. - using ReactiveUI.Primitives.Disposables; namespace ReactiveUI.Primitives.Extensions.Operators; /// Projects each element to an asynchronous operation, but only the result of the latest operation is emitted. -/// The type of elements in the source sequence. -/// The type of the result of the asynchronous operation. -/// The source observable. -/// The asynchronous projection function. -public sealed class SelectLatestAsyncObservable( - IObservable source, - Func> selector) : IObservable +/// The type of elements in the source sequence. +/// The type of the result of the asynchronous operation. +/// The source observable. +/// The asynchronous projection function. +public sealed class SelectLatestAsyncObservable(IObservable source, Func> selector) : IObservable { /// public IDisposable Subscribe(IObserver observer) @@ -21,18 +18,15 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(source); InvalidOperationExceptionHelper.ThrowIfNull(selector); ArgumentExceptionHelper.ThrowIfNull(observer); - SelectLatestAsyncSink sink = new(observer, selector); var sub = source.Subscribe(sink); return new DisposableBag(sub, sink); } - /// Sink that manages the latest async projection. - /// The downstream observer. - /// The async selector. - private sealed class SelectLatestAsyncSink( - IObserver downstream, - Func> selector) : IObserver, IDisposable + /// Processes source values and owns the subscription state. + /// The downstream observer. + /// The asynchronous operation. + internal sealed class SelectLatestAsyncSink(IObserver downstream, Func> selector) : IObserver, IDisposable { /// The gate for state access. private readonly Lock _gate = new(); @@ -53,25 +47,8 @@ private sealed class SelectLatestAsyncSink( private bool _disposed; /// - public void OnNext(TSource value) - { - long id; - lock (_gate) - { - if (_sourceCompleted || _disposed) - { - return; - } - - id = ++_currentId; - } - - var task = ProcessAsync(value, id); - lock (_gate) - { - _latestTask = task; - } - } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public void OnNext(TSource value) => _ = OnNextAsync(value); /// public void OnError(Exception error) @@ -110,10 +87,7 @@ public void OnCompleted() return; } - _ = toAwait.ContinueWith( - static (_, s) => ((SelectLatestAsyncSink)s!).SignalCompleted(), - this, - TaskScheduler.Default); + _ = toAwait.ContinueWith(static (_, s) => ((SelectLatestAsyncSink)s!).SignalCompleted(), this, TaskScheduler.Default); } /// @@ -125,8 +99,33 @@ public void Dispose() } } + /// Processes a value and returns its active operation. + /// The source value. + /// The processing task, or a completed task when no work starts. + internal Task OnNextAsync(TSource value) + { + long id; + lock (_gate) + { + if (_sourceCompleted || _disposed) + { + return Task.CompletedTask; + } + + id = ++_currentId; + } + + var task = ProcessAsync(value, id); + lock (_gate) + { + _latestTask = task; + } + + return task; + } + /// Signals downstream completion exactly once after the latest projection has finished. - private void SignalCompleted() + internal void SignalCompleted() { lock (_gate) { @@ -141,8 +140,8 @@ private void SignalCompleted() } /// Processes the async operation and checks for latest ID. - /// The value to project. - /// The ID of this operation. + /// The value to project. + /// The ID of this operation. /// A task representing the operation. private async Task ProcessAsync(TSource value, long id) { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs index 77e99137..98f0b109 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs @@ -6,13 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Fused .SelectMany(first).SelectMany(second) operator that chains two one-shot -/// async projections in a single operator allocation. The source emits a value, it's -/// projected through producing an intermediate observable, whose -/// single emission is then projected through producing the -/// final result. Errors at any stage propagate to the downstream observer. -/// +/// Chains two one-shot observable projections and forwards errors from either stage. /// The source element type. /// The intermediate element type produced by the first projection. /// The final element type produced by the second projection. @@ -34,9 +28,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new SourceWitness(observer, first, second)); } - /// Receives the source value and subscribes to the first projection. Holds a single - /// reusable created at subscribe time — the mid observer captures - /// only downstream and second, so the same instance handles every source emission. + /// Subscribes the reusable intermediate observer to the first projection. private sealed class SourceWitness : IObserver { /// The downstream observer that ultimately receives values. @@ -84,8 +76,7 @@ public void OnNext(TSource value) public void OnCompleted() => _downstream.OnCompleted(); } - /// Receives the intermediate value, applies second, and subscribes the resulting - /// observable directly to downstream — no separate final-stage observer needed. + /// Receives the intermediate value, applies second, and subscribes the resulting observable directly to downstream — no separate final-stage observer needed. /// The downstream observer. /// Second projection delegate. private sealed class MidWitness( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs index 1338eefc..fa3186ce 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs @@ -7,22 +7,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Operator that randomly shuffles arrays emitted by the source. +/// Shuffles arrays in place using non-cryptographic randomness. /// The array element type. /// The source observable emitting arrays. -/// -/// READ THIS BEFORE TOUCHING THE RANDOMNESS IN THIS FILE. -/// This operator deliberately uses non-cryptographic random (System.Random / Random.Shared). -/// This is intentional. We want random, and we want it fast. -/// Any analyzer or scanner warning that flags this as a security risk — for example CA5394 "Do not -/// use insecure randomness" or S2245 "Make sure that using this pseudorandom number generator is safe -/// here" — is a big false positive. Do not "fix" it. Do not swap in RandomNumberGenerator. -/// Why: this is a general-purpose shuffle for arbitrary observable data. It is not used for keys, -/// tokens, passwords, nonces, lottery draws, or anything security-sensitive. Crypto RNG here is slow, -/// slow, slow (a syscall for entropy on every draw) for zero real benefit. If you genuinely need -/// crypto-grade randomness, compose RandomNumberGenerator yourself in your own code — that is the -/// caller's job, not this hot-path operator's. Do not touch. -/// public sealed class ShuffleObservable(IObservable source) : IObservable { /// @@ -33,17 +20,13 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new ShuffleWitness(observer)); } - /// Observer that shuffles arrays in place. + /// Randomizes buffered values for non-security use. /// The downstream observer receiving shuffled arrays. - /// - /// INTENTIONAL non-crypto randomness. CA5394 here is a FALSE POSITIVE — see the banner at the top - /// of this file. This is a fast, general-purpose shuffle, NOT a security primitive. DO NOT TOUCH. - /// [SuppressMessage( "Security", "CA5394:Do not use insecure randomness", Justification = - "Shuffle is non-cryptographic by design; Random is faster and crypto RNG buys nothing here. FALSE POSITIVE.")] + "Array shuffling does not require cryptographic randomness.")] private sealed class ShuffleWitness(IObserver downstream) : IObserver { #if !NET8_0_OR_GREATER @@ -63,7 +46,6 @@ public void OnNext(T[] value) } #if NET8_0_OR_GREATER - // Random.Shared.Shuffle: fast, thread-safe, NON-CRYPTO BY DESIGN. WE WANT THIS. DO NOT TOUCH. Random.Shared.Shuffle(value); #else ShuffleInPlace(value); @@ -85,8 +67,7 @@ public void OnNext(T[] value) /// The array to shuffle in place. private static void ShuffleInPlace(T[] array) { - // Plain System.Random on purpose. NON-CRYPTO IS INTENTIONAL — fast path, not a security - // primitive. Any "insecure randomness" warning here is a FALSE POSITIVE. DO NOT TOUCH. + // This shuffle requires no cryptographic randomness. var random = _threadRandom; if (random is null) { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs index 13a42849..c71ffcfa 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs @@ -6,13 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Filtering operator that drops leading values, then forwards every value -/// (including subsequent s) once the latch has opened. Replaces the previous -/// source.SkipWhile(x => x == null) composition that delegated to System.Reactive's -/// SkipWhile, eliminating the per-subscription closure allocation and the extra observer -/// layer the chain introduced. -/// +/// Drops leading null values, then forwards every value, including subsequent nulls. /// The element type of the source observable; must be a reference type so the /// null check is meaningful. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs index 26ec9eba..afaa9203 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs @@ -65,26 +65,35 @@ public void Dispose() } } - /// Called when a new value is emitted by the source. - /// The value emitted by the source. - private void OnNext(T value) + /// Queues a source value and returns the operation started by it. + /// The source value. + /// The processing task, or a completed task if no work starts. + internal Task OnNextAsync(T value) { + var processing = Task.CompletedTask; lock (_gate) { if (_done || _disposed) { - return; + return Task.CompletedTask; } _queue.Enqueue(value); if (!_isProcessing) { _isProcessing = true; - _ = ProcessNextAsync(); + processing = ProcessNextAsync(); } } + + return processing; } + /// Queues a source value. + /// The source value. + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private void OnNext(T value) => _ = OnNextAsync(value); + /// Called when an error occurs in the source. /// The error that occurred. private void OnError(Exception error) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs index 80a3cabe..c26fdc65 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs @@ -24,7 +24,7 @@ public IDisposable Subscribe(IObserver<(T Value, IDisposable Sync)> observer) /// The sink for the . /// The downstream observer. - private sealed class SynchronizeAsyncSink(IObserver<(T Value, IDisposable Sync)> downstream) : IObserver, IDisposable + internal sealed class SynchronizeAsyncSink(IObserver<(T Value, IDisposable Sync)> downstream) : IObserver, IDisposable { /// The gate for state access. private readonly Lock _gate = new(); @@ -47,8 +47,6 @@ public void OnNext(T value) } } - // Implementation note: The original used 'new Continuation().Lock(item, observer)'. - // This is complex and stateful, so we maintain that logic in a way that respects sequentiality. _ = ProcessAsync(value); } @@ -91,12 +89,7 @@ public void Dispose() } } - /// - /// Processes the value. Pushes (value, signal) downstream and waits for the consumer - /// to dispose the signal. The fast path (consumer disposes synchronously inside OnNext) - /// returns a completed task without allocating a state machine or ; - /// the slow path (consumer defers disposal) lazily promotes the signal to a TCS-backed gate. - /// + /// Delivers the value and waits for the consumer to dispose its acknowledgement signal. /// The value to process. /// A representing the asynchronous operation. private Task ProcessAsync(T value) @@ -106,13 +99,8 @@ private Task ProcessAsync(T value) return signal.WaitForDisposeAsync(); } - /// - /// Per-emission gate: the downstream receives this handle as Sync. The producer - /// calls after pushing the value; synchronous disposal - /// short-circuits to with no TCS allocation. Late - /// (asynchronous) disposal lazily allocates a single . - /// - private sealed class SyncSignal : IDisposable + /// Releases one emission when the consumer disposes it, allocating completion state only for an asynchronous wait. + internal sealed class SyncSignal : IDisposable { /// The lazily-created completion source; only allocated on the slow path. private TaskCompletionSource? _tcs; @@ -120,11 +108,20 @@ private sealed class SyncSignal : IDisposable /// Latches to 1 on the first dispose so signalling is idempotent. private int _disposed; - /// Returns the task the producer should await before completing the emission. - /// The producer calls this exactly once per signal, so the TCS is published with a plain - /// volatile write rather than a compare-exchange. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + Volatile.Read(ref _tcs)?.TrySetResult(true); + } + + /// Returns the disposal task; the producer must call this exactly once per emission. /// A completed task if the consumer already disposed; otherwise the lazily-allocated TCS task. - public Task WaitForDisposeAsync() + internal Task WaitForDisposeAsync() { if (Volatile.Read(ref _disposed) == 1) { @@ -137,24 +134,9 @@ public Task WaitForDisposeAsync() return tcs.Task; } - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - Volatile.Read(ref _tcs)?.TrySetResult(true); - } - - /// Self-completes the just-published TCS if a dispose raced ahead of the publish and could - /// not see it, so the producer's await never hangs. + /// Completes the acknowledgement task when disposal overlaps its publication. /// The completion source published for this signal. - /// The set-result is only taken when a concurrent dispose latches between the publish and - /// this re-check; isolated here and excluded from coverage as race-only. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private void CompleteIfDisposedRaced(TaskCompletionSource tcs) + internal void CompleteIfDisposedRaced(TaskCompletionSource tcs) { if (Volatile.Read(ref _disposed) != 1) { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs index 0fb39716..01201e07 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Takes elements from the source sequence until a predicate returns true for an element. -/// The element that satisfies the predicate is included in the sequence. -/// +/// Takes elements from the source sequence until a predicate returns true for an element. The element that satisfies the predicate is included in the sequence. /// The type of elements in the source sequence. /// The source observable. /// The predicate to determine when to stop taking elements. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs b/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs index 6ed804ab..e6c368f1 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs @@ -6,13 +6,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Shared lock + timer + done-flag triple used by the synchronous timer-driven operator sinks -/// (Debounce-Until, Detect-Stale, Throttle-Distinct, Buffer-Until-Idle, etc.). Each of those sinks -/// previously hand-rolled three identical OnError / OnCompleted / Dispose method bodies on top of -/// the same fields; this helper centralises the bodies so the per-sink class only carries the -/// operator-specific OnNext logic. Sinks compose one instance and forward to it — no base class. -/// +/// Stores timer ownership, disposal state, and terminal notification state. /// The element type the downstream observer receives. /// The downstream observer terminal callbacks fan out to. [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs index 79c5db64..54d95eb6 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs @@ -22,14 +22,9 @@ internal static class ActivationExtensionsEmitter /// The indentation the generated overloads' bodies and constraints sit at. private const int BodyIndent = MethodIndent + SourceFileWriter.IndentWidth; - /// Emits every generated activation overload into one partial-class file. + /// Emits concrete activation overloads that take precedence over the generic placeholder. /// The overloads to emit, in request order. /// The generated source. - /// - /// The overloads join the same partial class as the placeholder they displace, so a call site binds to the - /// concrete overload without the consumer importing anything: a non-generic candidate beats the generic - /// placeholder outright. - /// internal static string Emit(ImmutableArray models) { var builder = new PooledStringBuilder(ScaffoldCapacity + (models.Length * OverloadCapacity)); diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs index fa50f9e4..fd68afb6 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs @@ -4,14 +4,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration; -/// The API a consumer writes against, emitted into every compilation. -/// -/// This is what makes the generator opt-in without a package-level runtime dependency: the placeholder -/// Events<T> gives a call site something to bind to while it is being typed, and the generated overload -/// for the receiver's own type displaces it once the type resolves. The attribute has to exist here too because a -/// static host has no receiver to hang a call off, and nothing here is annotated, so this is the one generated file -/// that carries no #nullable directive. -/// +/// Declares the generic activation placeholder and static-host attribute. internal static class ActivationSource { /// The source text added to every compilation. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs index 056298b1..f995af85 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs @@ -7,13 +7,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration; -/// Builds the wrapper class and generated file names, from the identity of the host they belong to. -/// -/// Sanitizing punctuation out of a fully qualified name maps distinct hosts onto the same identifier - -/// Samples.A_B.C and Samples.A.B_C both flatten to Samples_A_B_C - so a hash of the unflattened -/// identity is appended to keep them apart, while the readable half keeps a generated file recognisable in a build -/// log. -/// +/// Builds readable identifiers with an identity hash to distinguish flattened names. internal static class GeneratedNames { /// The prefix of a generated wrapper class name. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs index d5e9ceb3..7e25fc67 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration; -/// A fluent builder for generated source, backed by thread-local pooled character buffers. -/// -/// The free list is thread-local rather than a shared pool: source-output callbacks run concurrently, fragment -/// builders nest inside file builders, and nothing here outlives the call that rented it. Returning a buffer is what -/// buys the reuse; forgetting to costs reuse, never correctness. -/// +/// Builds source text using buffers rented from a thread-local pool. internal sealed class PooledStringBuilder { /// The smallest buffer worth renting, sized to hold a typical fragment without growing. @@ -85,13 +80,9 @@ internal PooledStringBuilder Append(char value) return this; } - /// Appends the invariant decimal rendering of a non-negative integer. + /// Appends an integer directly to the buffer without an intermediate string. /// The value to append; the only callers pass a name length, so it is never negative. /// This builder, for chaining. - /// - /// Formats digits straight into the buffer; these appends sit in the per-event loop that builds the mangled - /// static property names, where ToString would allocate a string per name segment. - /// internal PooledStringBuilder Append(int value) { EnsureCapacity(_position + MaxIntegerDigits); @@ -153,14 +144,10 @@ internal PooledStringBuilder AppendIndent(int spaces) return this; } - /// Appends a block of newline-separated lines, indenting each non-empty one. + /// Appends indented text, leaving empty lines free of whitespace. /// The block to append, which may be empty. /// The indentation width applied to every non-empty line. /// This builder, for chaining. - /// - /// Blank lines are left bare rather than filled with spaces, so an indented block never carries trailing - /// whitespace into the generated file. - /// internal PooledStringBuilder AppendIndentedLines(string value, int spaces) { var start = 0; @@ -187,8 +174,7 @@ internal PooledStringBuilder AppendIndentedLines(string value, int spaces) return this; } - /// Hands the buffer back to the thread's free list. - /// The builder must not be appended to afterwards. + /// Returns the buffer to the pool, ending this builder's lifetime. internal void Return() { var toReturn = _buffer; diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs index 58448aef..c613ae05 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs @@ -51,15 +51,11 @@ internal static void AppendFooter(PooledStringBuilder builder, string namespaceN _ = builder.AppendLine("}"); } - /// Appends the observable property that wraps one event. + /// Emits a local event handler with the delegate's return type. /// The destination builder. /// The event to wrap. /// The observable implementation to write against. /// The indentation the property sits at. - /// - /// The handler is emitted as a local function rather than a lambda so a delegate returning Task or - /// ValueTask can satisfy its own signature. - /// internal static void AppendEventProperty( PooledStringBuilder builder, EventModel model, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs index db30e7cc..8e3a1822 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs @@ -15,14 +15,10 @@ internal static class StaticEventsEmitter /// The room to reserve per generated observable property. private const int PropertyCapacity = 512; - /// Emits the static observable properties for one namespace. + /// Emits static event properties in a partial class within each requested namespace. /// The namespace and its static events. /// The observable implementation to write against. /// The generated source. - /// - /// A static event has no instance to hang an extension method off, so it is reached through a class named the - /// same in every namespace, left partial so a consumer can add to it. - /// internal static string Emit(StaticNamespaceModel model, ObservableProvider provider) { var events = model.Events.AsArray(); diff --git a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs index 59211884..bbc69cf5 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs @@ -12,13 +12,7 @@ namespace ReactiveUI.Primitives.ObservableEvents; /// Generates observable wrappers for the event-bearing types a consumer asks for. -/// -/// Generation is requested either by an Events() call, whose receiver names the host, or by an assembly -/// attribute naming a static host; both converge on the same extraction and the same emitters. Everything leaving a -/// semantic transform is a model of strings that compares by value, and every output is keyed on the smallest model -/// that decides it - one wrapper per host, one file per namespace of static events, one file of activation -/// overloads - so an edit to one host's events re-emits that host's file alone. -/// +/// Requests come from Events() calls or static-host attributes. Output is cached per host, namespace, and activation signature. [Generator(LanguageNames.CSharp)] public sealed class EventGenerator : IIncrementalGenerator { @@ -27,8 +21,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { RegisterActivationOutput(in context); - // Which observable library is referenced decides every type name in the generated source but nothing about - // which events exist, so resolving it separately keeps extraction from re-running when references move. + // Resolve provider names independently so reference changes do not invalidate event extraction. var provider = context.CompilationProvider .Select(static (compilation, _) => ProviderResolver.Resolve(compilation)) .WithTrackingName(GeneratorStepNames.Provider); @@ -57,14 +50,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) RegisterStaticOutputs(in context, staticTargets, provider); } - /// Registers the activation API a consumer writes against. + /// Registers activation source without modifying the semantic input compilation. /// The generator initialization context. - /// - /// An ordinary source output rather than post-initialization output: post-initialization source is added to the - /// compilation the pipeline then runs against, making that compilation new on every run and discarding every - /// semantic result cached against the one it replaces. One inert post-initialization file costs that, so this - /// generator registers none. - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void RegisterActivationOutput(in IncrementalGeneratorInitializationContext context) => context.RegisterSourceOutput( @@ -96,14 +83,10 @@ private static void RegisterInstanceOutputs( static (output, data) => EmitActivationOverloads(in output, data.Left, data.Right)); } - /// Registers the per-namespace static wrapper files and their request diagnostics. + /// Registers per-request diagnostics and per-namespace static event output. /// The generator initialization context. /// The distinct requested static hosts. /// The resolved observable implementation. - /// - /// Diagnostics hang off the individual requests while source hangs off the namespace groups, because a request - /// that produced no events still has something to say about itself but contributes nothing to a file. - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void RegisterStaticOutputs( in IncrementalGeneratorInitializationContext context, @@ -199,14 +182,10 @@ private static void EmitStaticNamespace( output.AddSource(model.HintName, StaticEventsEmitter.Emit(model, provider)); } - /// Reports that nothing can be generated for a request because no provider is referenced. + /// Reports a missing provider, or the extraction diagnostics when a provider is available. /// The source-production context. /// The requested host's readable name. /// Where the request was written. - /// - /// Reported instead of, not alongside, whatever else extraction found: without a provider nothing would compile - /// anyway, and the one actionable thing to say is which package to reference. - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void ReportMissingProvider( in SourceProductionContext output, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs index d2f22bd4..ecc2c5d8 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.ObservableEvents; -/// The names the pipeline's steps are tracked under. -/// -/// A driver told to track steps reports, per run, whether each named step recomputed and whether its value changed, -/// which is how a test asserts that the models compare by value and the caching holds. -/// +/// Names the pipeline steps reported by incremental tracking. internal static class GeneratorStepNames { /// The step that resolves which observable implementation is referenced. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventExtractor.cs index 12ed97ec..b571b99f 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventExtractor.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventExtractor.cs @@ -17,16 +17,11 @@ internal static class EventExtractor /// The parameter count of the conventional sender-and-arguments event delegate. private const int SenderAndArgsParameterCount = 2; - /// Collects the events a host can expose, recording a diagnostic for each one it cannot. + /// Extracts inherited events, preferring the most derived declaration for duplicate names. /// Everything about the host and where it was requested from. /// The destination for anything found wrong. /// A token that cancels the walk. /// The supported events, in declaration order from the host down to its last base type. - /// - /// Walking the base chain by hand rather than asking for all members at once is what lets a derived host expose - /// an event it inherits. A name already seen is skipped so an event redeclared in a derived type wins over the - /// one it hides, which is the member a consumer's own code would bind to. - /// internal static EquatableArray Collect( in EventRequest request, List diagnostics, @@ -67,15 +62,10 @@ internal static EquatableArray Collect( return events.Count == 0 ? EquatableArray.Empty : new([.. events]); } - /// Builds the mangled property name a static event gets on its namespace's shared class. + /// Builds an unambiguous static property name from length-prefixed host segments. /// The requested static host. /// The event name. /// The generated property name. - /// - /// Every namespace's static events share one class, so the host's name has to be part of the property name. - /// Concatenating names would let distinct hosts collide - A.BC and AB.C both flattening to - /// ABC - so each segment is length-prefixed, which no pair of different segmentations can produce. - /// private static string StaticPropertyName(INamedTypeSymbol host, string eventName) { var containers = new Stack(); @@ -118,14 +108,10 @@ private static EventModel CreateModel(IEventSymbol eventSymbol, in EventRequest SymbolHelpers.EscapeXml(eventSymbol.Name)); } - /// Builds the expression the generated handler is added to and removed from. + /// Qualifies event access with its declaring type to resolve hidden inherited members. /// The event to subscribe to. /// The host request the event was reached through. /// The subscription target expression. - /// - /// An instance target is cast to the type that declares the event rather than to the requested host, so an - /// event the host inherits and hides with a member of the same name still binds to the one being wrapped. - /// private static string BuildEventAccess(IEventSymbol eventSymbol, in EventRequest request) { var builder = new PooledStringBuilder(); @@ -143,15 +129,10 @@ private static string BuildEventAccess(IEventSymbol eventSymbol, in EventRequest return builder.Append('.').Append(SymbolHelpers.EscapeIdentifier(eventSymbol.Name)).ToStringAndReturn(); } - /// Selects the observable payload type and the value handed to the observer. + /// Selects event payloads: arguments for sender/args delegates, otherwise all parameters. /// The delegate's invocation method. /// The host request the event was reached through. /// The payload type and value, both empty for a parameterless delegate. - /// - /// The two-parameter sender/args shape is the one nearly every .NET event has, and its sender is the object the - /// consumer already holds, so only the args are surfaced. Anything else is passed through whole: a single - /// parameter as itself, several as a tuple. - /// private static (string PayloadType, string PayloadValue) SelectPayload( IMethodSymbol invokeMethod, in EventRequest request) @@ -177,14 +158,10 @@ private static (string PayloadType, string PayloadValue) SelectPayload( $"({JoinParameterNames(invokeMethod)})"); } - /// Renders the generated handler's parameter list. + /// Builds handler parameters with the delegate's nullability annotations. /// The delegate's invocation method. /// The host request the event was reached through. /// The comma-separated parameter declarations. - /// - /// The declared types carry whatever annotations the delegate declared, because a handler is only assignable to - /// a delegate whose parameter nullability it matches. - /// private static string JoinParameterDeclarations(IMethodSymbol invokeMethod, in EventRequest request) { var builder = new PooledStringBuilder(); @@ -240,15 +217,10 @@ private static string SelectHandlerReturnValue(IMethodSymbol invokeMethod, WellK return returnsTask ? Constants.CompletedTask : Constants.DefaultValueTask; } - /// Determines why an event cannot be exposed as an observable. + /// Returns why an event cannot be captured or exposed to an observer. /// The event to validate. /// The task types resolved from the consumer compilation. /// The reason, or when the event is supported. - /// - /// The generated handler hands its parameters to an observer, which outlives the callback, so anything that - /// cannot leave the stack or be captured has to be refused here rather than emitted and left to fail the - /// consumer's build. - /// private static string? SelectUnsupportedReason(IEventSymbol eventSymbol, WellKnownTypes wellKnownTypes) { if (eventSymbol.Type is not INamedTypeSymbol { DelegateInvokeMethod: { } invokeMethod }) diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventRequest.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventRequest.cs index 9669426f..164d3288 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventRequest.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventRequest.cs @@ -7,17 +7,13 @@ namespace ReactiveUI.Primitives.ObservableEvents.Helpers; -/// One host to extract events from, with everything the extraction needs to render them. +/// Groups compiler symbols used only within an event extraction transform. /// The type whose events are being wrapped. /// Whether static rather than instance events are wanted. /// The generated type-parameter names, or null for a non-generic host. /// Whether the consumer's language can express an annotation. /// Where the request was written, for diagnostics. /// The task types resolved from the consumer compilation. -/// -/// This carries symbols and so never leaves the semantic transform that created it; what comes back out is a model -/// of strings. Bundling the arguments keeps the extraction methods from growing a parameter list each. -/// internal readonly record struct EventRequest( INamedTypeSymbol Host, bool IsStatic, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs index 99ec4e9f..a3affeae 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs @@ -12,14 +12,10 @@ namespace ReactiveUI.Primitives.ObservableEvents.Helpers; /// Turns an Events() call site into the model of the wrapper it asks for. internal static class InstanceTargetExtractor { - /// Cheaply rejects syntax that cannot be an activation call. + /// Identifies candidate activation calls by syntax alone. /// The node under consideration. /// A token that cancels the check. /// when the node is a parameterless Events() member invocation. - /// - /// This runs on every node of every edited file, so it only looks at shape and spelling. Deciding whether the - /// call is really ours needs the semantic model, and is left to the transform that runs on the survivors. - /// internal static bool IsActivationInvocation(SyntaxNode node, CancellationToken cancellationToken) { _ = cancellationToken; @@ -31,16 +27,10 @@ internal static bool IsActivationInvocation(SyntaxNode node, CancellationToken c && memberAccess.Name.Identifier.ValueText == Constants.EventMethodName; } - /// Resolves an activation call into the host it wraps. + /// Extracts the receiver type from an unresolved activation call. /// The semantic context for the candidate call. /// A token that cancels the resolution. /// The requested host, or for an unrelated call. - /// - /// The activation placeholder this call will eventually bind to is this generator's own output, and output is - /// not visible to the pipeline that produced it - so during a run the call resolves to nothing. That absence is - /// the signal: a call that does resolve belongs to somebody else and is left alone, and a call that - /// does not is ours to answer. What the request needs is the receiver's type, which binds on its own. - /// internal static InstanceTargetModel? Extract(GeneratorSyntaxContext context, CancellationToken cancellationToken) { var invocation = (InvocationExpressionSyntax)context.Node; @@ -62,17 +52,13 @@ internal static bool IsActivationInvocation(SyntaxNode node, CancellationToken c : null; } - /// Builds the model for one requested host. + /// Extracts a host's original generic definition for a shared wrapper. /// The host to wrap, reduced to its original definition. /// The call site, for diagnostics. /// Whether the consumer's language can express an annotation. /// The task types resolved from the consumer compilation. /// A token that cancels the walk. /// The host model. - /// - /// A generic host is reduced to its original definition so that Foo<int> and - /// Foo<string> share one wrapper, generic in the same parameters the host is. - /// private static InstanceTargetModel Create( INamedTypeSymbol host, LocationInfo? location, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/LanguageSupport.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/LanguageSupport.cs index dbd47acb..da2b3be1 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/LanguageSupport.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/LanguageSupport.cs @@ -10,19 +10,9 @@ namespace ReactiveUI.Primitives.ObservableEvents.Helpers; /// What the consumer's language version lets the generated source say. internal static class LanguageSupport { - /// Determines whether the consumer's language can express a nullable reference type. + /// Determines whether the C# language version supports nullable annotations and directives. /// A syntax tree from the consumer, which carries the language version it was parsed at. /// when annotations and the nullable directive may be emitted. - /// - /// Everything about the generated file's nullability follows from this one answer: whether it opens with - /// #nullable enable, and whether the handler signatures carry the annotations that make them match the - /// delegates they are assigned to. Emitting either against an older language version is a compile error in the - /// consumer's build, so both are decided together and from the same place. - /// - /// - /// The cast is safe by registration: the generator is declared for C# only, so every tree it is ever handed - /// was parsed with C# options. - /// internal static bool SupportsNullableAnnotations(SyntaxTree tree) => ((CSharpParseOptions)tree.Options).LanguageVersion >= LanguageVersion.CSharp8; } diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/ProviderResolver.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/ProviderResolver.cs index 3c5c4387..e091b4c4 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/ProviderResolver.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/ProviderResolver.cs @@ -10,15 +10,9 @@ namespace ReactiveUI.Primitives.ObservableEvents.Helpers; /// Chooses which observable implementation the generated code is written against. internal static class ProviderResolver { - /// Selects the best provider visible to a consumer. + /// Selects a fully referenced provider, preferring Primitives over System.Reactive. /// The consumer compilation. /// The selected provider, or when none is referenced. - /// - /// Lean wins over reactive when both are referenced, because a consumer that has the lean package on hand is - /// asking for the allocation-free payload; falling back to Unit there would be a silent downgrade. Each - /// candidate is confirmed by every type its generated code names, so a partial reference set moves on to the - /// next candidate instead of emitting source that will not compile. - /// internal static ObservableProvider Resolve(Compilation compilation) { if (HasTypes( diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs index c944f178..37bca49c 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs @@ -9,13 +9,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Helpers; -/// Turns each GenerateStaticEventObservables application into the model of the host it names. -/// -/// Matched on how the attribute is written rather than on the symbol it binds to. The attribute is declared by this -/// generator's own output, and output is not visible to the pipeline that produced it, so there is no symbol to -/// match against while the pipeline runs. What the request actually needs - the host type - comes from the -/// typeof argument, which binds on its own. -/// +/// Matches activation attributes by syntax because their declarations are generated output. internal static class StaticTargetExtractor { /// Cheaply rejects syntax that cannot be a static generation request. @@ -31,15 +25,10 @@ internal static bool IsStaticRequestAttribute(SyntaxNode node, CancellationToken && IsRequestAttributeName(attribute.Name); } - /// Resolves one static request into the host it names. + /// Extracts static-host requests, ignoring missing or invalid type arguments. /// The semantic context for the candidate attribute. /// A token that cancels the resolution. /// The requested host, or when the attribute names nothing usable. - /// - /// An attribute that names nothing usable - written without an argument, or with one that is not a type - is - /// skipped rather than diagnosed: the consumer is already being told about it by the compiler, and a half-typed - /// attribute should not add a second complaint on every keystroke. - /// internal static StaticTargetModel? Extract(GeneratorSyntaxContext context, CancellationToken cancellationToken) { var attribute = (AttributeSyntax)context.Node; @@ -77,17 +66,13 @@ private static bool IsRequestAttributeName(NameSyntax name) _ => string.Empty, }; - /// Builds the model for one requested static host. + /// Rejects generic static hosts whose type arguments cannot be inferred. /// The host to expose, reduced to its original definition. /// The attribute application, for diagnostics. /// Whether the consumer's language can express an annotation. /// The task types resolved from the consumer compilation. /// A token that cancels the walk. /// The host model. - /// - /// A generic host is refused outright: its static events belong to each closed construction rather than to the - /// open type, and the generated class has no receiver to infer type arguments from. - /// private static StaticTargetModel Create( INamedTypeSymbol host, LocationInfo? location, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs index fb116e7d..2b5ca5d2 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs @@ -21,26 +21,16 @@ internal static class SymbolHelpers /// Renders a type without nullable annotations, for a consumer whose language predates them. private static readonly SymbolDisplayFormat ObliviousFormat = SymbolDisplayFormat.FullyQualifiedFormat; - /// Renders a type with its nullable annotations. - /// - /// The generated handler has to match the delegate it is assigned to exactly. A delegate declared with an - /// annotated parameter - EventHandler and its sender being the one nearly every event goes through - - /// does not match a handler that declares the same parameter unannotated, and the consumer's build says so. - /// + /// Formats a type with the nullability required by its delegate signature. private static readonly SymbolDisplayFormat AnnotatedFormat = SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); - /// Renders a type as a fully qualified reference, substituting renamed type parameters. + /// Substitutes type parameter names by symbol, preserving other display parts. /// The type to render. /// The generated type-parameter names, or null when none were renamed. /// Whether the consumer's language can express an annotation. /// The fully qualified type reference. - /// - /// Walking display parts rather than the finished string is what makes the substitution safe: a part carrying a - /// type parameter is identified by its symbol, so a parameter named T is replaced while a type whose name - /// merely contains T is left alone. - /// internal static string Display( ITypeSymbol symbol, IReadOnlyDictionary? typeParameterNames, @@ -68,13 +58,9 @@ internal static string Display( return builder.ToStringAndReturn(); } - /// Collects the type parameters a wrapper has to redeclare, outermost container first. + /// Collects the host's type parameters, including those inherited from containing types. /// The host type. /// The complete ordered type-parameter list. - /// - /// A wrapper for a nested generic sits at namespace level, so it has to redeclare every parameter its host - /// inherits from its containing types as well as its own. - /// internal static List CollectTypeParameters(INamedTypeSymbol type) { var containers = new Stack(); @@ -92,14 +78,9 @@ internal static List CollectTypeParameters(INamedTypeSymbo return result; } - /// Assigns each type parameter a name that is unique across the flattened list. + /// Assigns distinct names to type parameters from nested generic scopes. /// The ordered type parameters. /// The symbol-to-generated-name mapping. - /// - /// Flattening a nested generic can collide two parameters that were distinct in their own scopes - - /// Outer<T>.Inner<T> being the usual shape - so the second one is suffixed rather than - /// silently shadowing the first. - /// internal static Dictionary CreateTypeParameterNames( List typeParameters) { @@ -149,15 +130,11 @@ internal static string BuildTypeParameterList( return builder.Append('>').ToStringAndReturn(); } - /// Renders the generic constraint clauses, one per line and without indentation. + /// Formats constraint clauses without indentation. /// The ordered type parameters. /// The generated type-parameter names. /// Whether the consumer's language can express an annotation. /// The constraint clauses, or an empty string when nothing is constrained. - /// - /// Left unindented because the same clauses are emitted at two different depths - once on the wrapper class and - /// once on the activation overload - and the emitter is what knows which. - /// internal static string BuildConstraints( List typeParameters, IReadOnlyDictionary typeParameterNames, @@ -215,15 +192,11 @@ internal static string EscapeXml(string value) return builder.ToStringAndReturn(); } - /// Appends one type parameter's comma-separated constraints. + /// Appends the single applicable primary constraint before other constraints. /// The destination builder. /// The constrained type parameter. /// The generated type-parameter names. /// Whether the consumer's language can express an annotation. - /// - /// The primary constraint has to come first and only one of the four forms may appear, which is why they are - /// tested in order rather than accumulated. - /// private static void AppendConstraintClause( PooledStringBuilder builder, ITypeParameterSymbol parameter, @@ -268,8 +241,7 @@ private static string SelectPrimaryConstraint(ITypeParameterSymbol parameter, bo if (parameter.HasReferenceTypeConstraint) { - // A referenced assembly can declare `class?` whatever the consumer's language version is, so the - // annotation has to be dropped rather than repeated when the consumer could not have written it. + // Omit referenced nullable constraints when the consumer's language cannot express them. return supportsNullableAnnotations && parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated ? "class?" diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs index 261521a9..6b8a3ed4 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs @@ -8,12 +8,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Helpers; -/// Reconciles the requests found across a compilation into the set of files to generate. -/// -/// Requests arrive one per call site or attribute, but generated files are keyed on the host or the namespace, so -/// this is where the two are brought back into line. Both passes keep the order the requests were found in, so the -/// generated output does not shuffle when an unrelated file is edited. -/// +/// Deduplicates requests by output identity, preserving discovery order. internal static class TargetCollections { /// The largest request count that cannot contain a duplicate. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/WellKnownTypes.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/WellKnownTypes.cs index 78b07aae..3bd157b4 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/WellKnownTypes.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/WellKnownTypes.cs @@ -6,13 +6,9 @@ namespace ReactiveUI.Primitives.ObservableEvents.Helpers; -/// The framework types an event delegate is allowed to return, resolved once per extraction. +/// Caches framework symbols used during event extraction. /// The resolved task type, or null when the consumer cannot see it. /// The resolved value task type, or null when the consumer cannot see it. -/// -/// Resolved up front rather than per event, because an async event host declares many events and each one would -/// otherwise repeat the same two metadata lookups. -/// internal readonly record struct WellKnownTypes(INamedTypeSymbol? Task, INamedTypeSymbol? ValueTask) { /// Resolves the types from a consumer compilation. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/ActivationModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/ActivationModel.cs index 37cd41e1..e16025bc 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/ActivationModel.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/ActivationModel.cs @@ -4,17 +4,13 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; -/// One strongly typed Events() overload, which replaces the placeholder for a given host. +/// Stores the signature that determines an activation overload. /// The fully qualified host reference the overload accepts. /// The fully qualified wrapper reference the overload returns. /// The overload's type parameter list, or empty for a non-generic host. /// One where clause per line without indentation, or empty when unconstrained. /// The host reference, escaped for the generated documentation comment. /// Whether the consumer's language can express an annotation. -/// -/// Kept apart from so the one file carrying every overload only re-emits when an -/// overload signature actually moves, rather than whenever any wrapper's events change. -/// internal sealed record ActivationModel( string TypeReference, string WrapperReference, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/DiagnosticInfo.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/DiagnosticInfo.cs index ef1f2ba0..faf1983f 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/DiagnosticInfo.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/DiagnosticInfo.cs @@ -6,16 +6,11 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; -/// A diagnostic held as values, so it can ride along in a model until it is reported. +/// Stores diagnostic content and coordinates without retaining compiler objects. /// The descriptor to report. /// Where to point, or when the request has no source location. /// The first message argument. /// The second message argument, when the descriptor takes one. -/// -/// A carries a and therefore a syntax tree, -/// which the pipeline can neither compare nor safely cache. Extraction records what to say and where; the source -/// output turns it back into a diagnostic. -/// internal sealed record DiagnosticInfo( DiagnosticDescriptor Descriptor, LocationInfo? Location, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/EquatableArray.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/EquatableArray.cs index 1f662292..4429af6d 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/EquatableArray.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/EquatableArray.cs @@ -6,15 +6,8 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; -/// An array that compares by value, so it can sit inside an incremental-pipeline model. +/// An immutable array with element-wise equality and a cached hash. /// The element type, which must itself compare by value. -/// -/// The pipeline decides whether to re-run a downstream step by asking whether the model it produced equals the one -/// from the previous run. An array compares by reference, so a model carrying a bare array is never equal to its -/// predecessor and every step below it re-runs on every keystroke. Wrapping the array here is what makes the -/// per-target caching real. A readonly struct so the value sits inline in its owning record rather than adding a -/// heap object per collection. -/// internal readonly struct EquatableArray : IEquatable> where T : notnull, IEquatable { @@ -59,11 +52,7 @@ internal EquatableArray(T[] values) public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); /// - /// - /// A defaulted instance and one wrapping a zero-length array are the same value here. Treating them as - /// different would make an extraction that happened to build an empty array compare unequal to one that - /// returned , and silently cost the caching this type exists for. - /// + /// Compares elements in order, treating default and empty arrays as equal. public bool Equals(EquatableArray other) { var values = _values; @@ -101,13 +90,9 @@ public bool Equals(EquatableArray other) [MethodImpl(MethodImplOptions.AggressiveInlining)] internal T[] AsArray() => _values ?? []; - /// Computes the deterministic hash of the elements. + /// Hashes elements in order, returning zero for an empty array. /// The elements to hash. /// The combined hash. - /// - /// Empty hashes to zero, which is what a default-constructed instance keeps in its field without calling - /// here - so the two forms of empty that calls equal hash alike. - /// private static int ComputeHashCode(T[] values) { if (values.Length == 0) diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs index 52c03a98..8a99328a 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; -/// One event, reduced to the exact fragments its generated observable property is assembled from. +/// Stores event emission data without compiler objects; parameterless payloads remain provider-independent. /// The generated property name, already escaped. /// The subscription target and event name, already escaped. /// The observable payload type, or empty when the delegate takes no parameters. @@ -14,12 +14,6 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; /// The handler's return expression, or empty when it returns void. /// Whether the generated property is static. /// The event name, escaped for the generated documentation comment. -/// -/// Everything the emitter needs is a string by this point: no symbol, syntax node, or compilation survives into the -/// pipeline. A parameterless delegate leaves the payload fields empty rather than naming a void type, because which -/// void type applies is the one thing that depends on the provider - keeping it out here is what lets a model stay -/// cached when only the consumer's references change. -/// internal sealed record EventModel( string PropertyName, string EventAccess, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/InstanceTargetModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/InstanceTargetModel.cs index 662534d9..28f6a178 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/InstanceTargetModel.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/InstanceTargetModel.cs @@ -35,12 +35,8 @@ internal sealed record InstanceTargetModel( EquatableArray Diagnostics, LocationInfo? Location) { - /// Creates the activation overload this host's wrapper is reached through. + /// Gets the activation signature independently of the host's event list. /// The overload model. - /// - /// Projected out rather than stored, so the one file carrying every overload compares equal - and stays - /// uncompiled - when a host's events change but its signature does not. - /// internal ActivationModel ToActivation() => new( TypeReference, diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/LocationInfo.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/LocationInfo.cs index 8fa140fb..145b2acc 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/LocationInfo.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/LocationInfo.cs @@ -8,15 +8,10 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; -/// Where a diagnostic points, reduced to values the incremental pipeline can compare. +/// Stores diagnostic coordinates without retaining syntax trees. /// The source file the request was written in. /// The span within that file. /// The line and character span within that file. -/// -/// A holds onto its syntax tree, which would pin a whole compilation in the pipeline's cache -/// and never compare equal between runs. Keeping the three values it is built from lets the location survive in a -/// model and be rebuilt at the point a diagnostic is actually reported. -/// internal sealed record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan) { /// Reduces a Roslyn location to its comparable values. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/ObservableProvider.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/ObservableProvider.cs index 8ff07a0f..26d3224b 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/ObservableProvider.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/ObservableProvider.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; -/// The observable implementation the generated code is written against. -/// -/// Resolved once from the consumer's references and flowed into emission as a value, so that the far more expensive -/// event extraction does not have to re-run when only the reference set moves. -/// +/// Stores the selected observable provider independently of event extraction. internal enum ObservableProvider { /// No supported observable factory is visible; nothing can be generated. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticNamespaceModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticNamespaceModel.cs index f9ccd3e8..b370ed1a 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticNamespaceModel.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticNamespaceModel.cs @@ -4,15 +4,11 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; -/// Every static event requested in one namespace, which share a single generated RxEvents class. +/// Groups deduplicated static event hosts by namespace. /// The generated file name for this namespace. /// The namespace to emit into, or empty for the global namespace. /// Whether the consumer's language can express an annotation. /// The static events to expose, in request order. -/// -/// Grouping happens after deduplication so that adding a request in one namespace leaves every other namespace's -/// file byte-identical, and therefore uncached only where it actually changed. -/// internal sealed record StaticNamespaceModel( string HintName, string Namespace, diff --git a/src/ReactiveUI.Primitives.Reactive/Concurrency/SequencerSchedulingExtensions.cs b/src/ReactiveUI.Primitives.Reactive/Concurrency/SequencerSchedulingExtensions.cs index af93b947..67a4f06e 100644 --- a/src/ReactiveUI.Primitives.Reactive/Concurrency/SequencerSchedulingExtensions.cs +++ b/src/ReactiveUI.Primitives.Reactive/Concurrency/SequencerSchedulingExtensions.cs @@ -10,11 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; -/// -/// Gives the ISequencer scheduling shape the shared Primitives source expects, -/// by forwarding to . Kept internal: it is the seam the leaf -/// uses to invoke schedulers, not public surface. -/// +/// Adapts IScheduler to the scheduling operations used by shared Primitives source. internal static class SequencerSchedulingExtensions { /// Scheduling operators that give an the shared-source sequencer shape. diff --git a/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs b/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs index f528e8cb..61a49691 100644 --- a/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs +++ b/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs @@ -3,19 +3,13 @@ // See the LICENSE file in the project root for full license information. using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using System.Reactive.Concurrency; -using Timer = System.Threading.Timer; namespace ReactiveUI.Primitives.Reactive.Concurrency; /// -/// Task-pool replacement for single-threaded event-loop runtimes such as browser WebAssembly: it never starts -/// threads, never blocks, and does not support long-running scheduling. Immediate work is batched one drain per -/// event-loop turn through a zero-due timer (a setTimeout(0) macrotask on WebAssembly, so the browser can -/// render between batches); delayed and periodic work use one-shot/periodic timers, which the WebAssembly runtime -/// backs with the JS event loop. Successor to the retired Reactive.Wasm package's scheduler, whose runtime -/// reflection no longer exists on modern .NET. +/// Schedules work on a single-threaded event loop without blocking or starting threads. Immediate work runs in +/// batches between event-loop turns; delayed and periodic work use timers. Long-running scheduling is unsupported. /// [System.Diagnostics.DebuggerDisplay("WasmScheduler: ReadyCount = {_readyCount}, DrainState = {_drainState}, Disposed = {_isDisposed}")] public sealed class WasmScheduler : LocalScheduler, ISchedulerPeriodic, IDisposable @@ -36,38 +30,36 @@ public sealed class WasmScheduler : LocalScheduler, ISchedulerPeriodic, IDisposa private readonly ConcurrentQueue _ready = new(); /// One-shot timer used to yield a drain to the event loop. - private readonly Timer _drainTimer; + private readonly ITimer _drainTimer; + + /// Creates the timers that dispatch scheduled work. + private readonly TimeProvider _timeProvider; /// Approximate number of ready items; snapshots a drain batch. private int _readyCount; - /// - /// Single-flight drain state: 0 idle, 1 a drain is running, 2 a drain is running and more - /// work arrived while it ran. Keeping at most one drain in flight preserves the single-threaded, FIFO, - /// one-drain-per-event-loop-turn semantics the type promises even though the backing timer may fire callbacks - /// on more than one thread-pool thread. - /// + /// Drain state: zero idle, one active, two active with another pass requested. private int _drainState; /// Non-zero once has released the drain timer and the ready queue. private int _isDisposed; - /// - /// Initializes a new instance of the class. Callers use ; this is - /// internal so a test can own an isolated scheduler it may dispose without shutting the shared singleton down for - /// every other test. - /// + /// Initializes a new instance of the class. + /// Timer provider; null selects the system provider. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Correctness", "SST2403:Do not let 'this' escape from a constructor", Justification = "The drain timer is created disarmed, so nothing can call back into it until Schedule arms it after construction.")] - internal WasmScheduler() => - _drainTimer = new( + internal WasmScheduler(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _drainTimer = _timeProvider.CreateTimer( static state => ((WasmScheduler)state!).RunDrain(), this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } /// A queued work item awaiting an event-loop drain or a one-shot timer. Disposing it cancels it. internal interface IReadyWorkItem : IDisposable @@ -125,7 +117,7 @@ public override IDisposable Schedule( // The timer roots itself while armed through the callback's target (the work item), which stores the // timer; the item's Dispose cancels and releases it. - item.AttachTimer(new Timer(static s => ((IReadyWorkItem)s!).Run(), item, dt, Timeout.InfiniteTimeSpan)); + item.AttachTimer(_timeProvider.CreateTimer(static s => ((IReadyWorkItem)s!).Run(), item, dt, Timeout.InfiniteTimeSpan)); return item; } @@ -153,14 +145,12 @@ public IDisposable SchedulePeriodic(TState state, TimeSpan period, Func< period = OneMillisecond; } - return PeriodicWorkItem.Start(state, period, action); + return PeriodicWorkItem.Start(state, period, action, _timeProvider); } /// - /// Releases the drain timer this scheduler owns and cancels the ready work still queued behind it. Scheduling - /// through a disposed scheduler throws rather than queueing work no drain - /// will ever reach. Work an in-flight drain has already dequeued runs to completion, and a delayed item that - /// already owns its one-shot timer keeps it — the caller cancels those through the disposable it was handed. + /// Releases the drain timer and cancels queued work. Subsequent scheduling throws ObjectDisposedException. + /// Running work completes; callers retain responsibility for cancelling delayed work through its returned handle. /// public void Dispose() { @@ -173,20 +163,14 @@ public void Dispose() ReleaseReady(); } - /// - /// Enqueues immediate work and coalesces a single drain post. Internal rather than private so a test can drive the - /// enqueue that was already past 's - /// disposed check when disposal drained the ready queue, and prove the item is released rather than stranded. - /// + /// Enqueues work and requests a drain, releasing the item if disposal overlaps. /// Work item to execute on the next event-loop turn. internal void Enqueue(IReadyWorkItem item) { QueueReady(item); PostDrain(); - // A disposal that raced the enqueue above may have drained the queue before this item joined it. Re-check - // the flag the disposal published first, so the loser of that race releases the item instead of leaving it - // queued behind a timer that can no longer fire. + // Release work enqueued after disposal drained the queue. if (!IsDisposed) { return; @@ -195,10 +179,7 @@ internal void Enqueue(IReadyWorkItem item) ReleaseReady(); } - /// - /// Adds a work item to the ready queue without arming a drain. Internal rather than private so a test can order an - /// enqueue, a disposal and a drain as explicit steps instead of racing them across threads. - /// + /// Adds ready work without requesting a drain. /// Work item to add to the ready queue. internal void QueueReady(IReadyWorkItem item) { @@ -206,10 +187,7 @@ internal void QueueReady(IReadyWorkItem item) _ = Interlocked.Increment(ref _readyCount); } - /// - /// Runs one batch: every item the ready count promised, stopping early if a concurrent drain took one first. - /// Internal rather than private so a test can run a drain pass at the point in a sequence it chooses. - /// + /// Runs the ready items in one batch. internal void RunReadyBatch() { for (var remaining = Volatile.Read(ref _readyCount); @@ -221,10 +199,31 @@ internal void RunReadyBatch() } } - /// - /// Cancels and drops every ready item. The items are the handles their callers hold, so disposing them releases - /// the caller's work instead of stranding it in a queue nothing will ever drain again. - /// + /// Claims a drain or requests another pass if the observed state is still current. + /// The drain state observed before attempting the transition. + /// True when no further claim attempt is needed; false when the observed state changed. + internal bool TryPostDrain(int observedState) + { + if (Volatile.Read(ref _readyCount) == 0) + { + return true; + } + + if (observedState != DrainIdle) + { + return Interlocked.CompareExchange(ref _drainState, DrainRunningPending, observedState) == observedState; + } + + if (Interlocked.CompareExchange(ref _drainState, DrainRunning, DrainIdle) != DrainIdle) + { + return false; + } + + ArmDrain(); + return true; + } + + /// Cancels and removes every queued work item. private void ReleaseReady() { while (_ready.TryDequeue(out var item)) @@ -234,49 +233,22 @@ private void ReleaseReady() } } - /// - /// Arms a single drain if none is in flight, otherwise flags the running drain to loop again. - /// - /// The whole body is the claim protocol: it spins only while a compare-exchange - /// loses to a concurrent claim, and exits early only when a concurrent drain empties the queue between the - /// caller's enqueue and this read. Neither path is reachable without a second thread interleaving, so the - /// shell carries the coverage exclusion; the work it schedules lives in ArmDrain, which is covered. - /// - /// - [ExcludeFromCodeCoverage] + /// Retries drain claims after competing state updates. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void PostDrain() { - while (Volatile.Read(ref _readyCount) != 0) + int state; + do { - var state = Volatile.Read(ref _drainState); - if (state != DrainIdle) - { - // A drain is already running; flag that more work arrived so it drains again. - if (Interlocked.CompareExchange(ref _drainState, DrainRunningPending, state) == state) - { - return; - } - - continue; - } - - // Become the sole drainer, then yield a batch to the event loop. - if (Interlocked.CompareExchange(ref _drainState, DrainRunning, DrainIdle) != DrainIdle) - { - continue; - } - - ArmDrain(); - return; + state = Volatile.Read(ref _drainState); } + while (!TryPostDrain(state)); } /// Yields the claimed drain batch to the event loop, or hands the latch back when disposal beat it. private void ArmDrain() { - // Arming a released timer is a silent no-op, so a claim made while the scheduler was being disposed would - // leave the latch set on a drain that can never run. Hand the latch back instead: with scheduling closed and - // the ready queue released, there is nothing left for that drain to do anyway. + // Release the drain claim when disposal prevents arming the timer. if (IsDisposed) { Volatile.Write(ref _drainState, DrainIdle); @@ -286,15 +258,7 @@ private void ArmDrain() _ = _drainTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); } - /// - /// Runs event-loop batches for the single in-flight drain until no more work is queued. - /// - /// This is a thin batching shell around . It repeats a pass only when a concurrent - /// flagged more work mid-pass, which needs a second thread to interleave, so the shell - /// carries the coverage exclusion and the per-item work lives in the method it calls. - /// - /// - [ExcludeFromCodeCoverage] + /// Drains queued batches until no further pass is requested. private void RunDrain() { do @@ -316,20 +280,11 @@ private void RunDrain() PostDrain(); } - /// - /// A cancellable scheduled work item carrying closure-free state and the scheduler passed back to the action; - /// also the target that roots a delayed one-shot timer. The run/cancel handshake lives in the shared - /// ; this item only adds the optional one-shot timer a delayed schedule - /// attaches. - /// + /// Owns scheduled work and its optional one-shot timer. /// The scheduled state type. internal sealed class StatefulWorkItem : DispatchWorkItemBase, IReadyWorkItem { - /// - /// Release handle for the one-shot timer driving a delayed item; for immediate work. - /// Typed as the cancellation the item actually performs rather than the timer itself, so a test can hand it a - /// recording handle and observe the release directly. - /// + /// The delayed item's timer release handle, or null for immediate work. private IDisposable? _timer; /// Initializes a new instance of the class. @@ -344,8 +299,7 @@ public StatefulWorkItem(WasmScheduler scheduler, TState state, Func public void Dispose() { - // Claim cancellation first so a racing AttachTimer observes the disposed state and releases the timer it - // just stored, then reclaim any timer this item already owns and the disposable the action returned. + // Publish cancellation before reclaiming timers so a concurrent attachment releases its handle. if (!TryClaimDispose()) { return; @@ -379,11 +333,8 @@ internal sealed class PeriodicWorkItem : IDisposable /// Scheduled action. private readonly Func _action; - /// - /// Periodic timer; rooted through the tick callback's target while armed. Attached by - /// once the item is fully constructed, so it is never for an item a caller can see. - /// - private Timer? _timer; + /// Periodic timer, attached after construction and rooted by its callback while armed. + private ITimer? _timer; /// State threaded through the periodic action. private TState _state; @@ -412,27 +363,27 @@ public void Dispose() _isDisposed = true; - // Start is the only construction path and always assigns the timer before returning, and a second - // Dispose exits at the flag above, so the timer is always present on the one pass that reaches here. + // Start assigns the timer before returning; only the first Dispose reaches this point. _timer!.Dispose(); _timer = null; _state = default!; } } - /// - /// Creates a periodic item and arms its timer. Arming it here rather than in the constructor is what keeps - /// the tick callback from ever seeing a half-built item: the timer is created disarmed, attached, and only - /// then started, so the first tick runs against an item whose fields are all published. - /// + /// Publishes the periodic item and its timer before enabling ticks. /// Initial state. /// Tick period. /// Scheduled action. + /// Timer provider. /// The armed periodic work item, which cancels the ticks when disposed. - internal static PeriodicWorkItem Start(TState state, TimeSpan period, Func action) + internal static PeriodicWorkItem Start( + TState state, + TimeSpan period, + Func action, + TimeProvider timeProvider) { PeriodicWorkItem item = new(state, action); - Timer timer = new( + var timer = timeProvider.CreateTimer( static s => ((PeriodicWorkItem)s!).Tick(), item, Timeout.InfiniteTimeSpan, @@ -443,10 +394,7 @@ internal static PeriodicWorkItem Start(TState state, TimeSpan period, Fu return item; } - /// - /// Runs one periodic tick. Internal rather than private so a test can drive the tick a timer callback already - /// in flight would deliver after won the race, and prove the action does not run. - /// + /// Runs one periodic tick unless the work item is disposed. internal void Tick() { lock (_gate) diff --git a/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs b/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs index 467c2919..02ce2775 100644 --- a/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs +++ b/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs @@ -11,19 +11,7 @@ namespace ReactiveUI.Primitives.Reactive.Disposables; /// , so an activation-scoped container flows into APIs written against /// System.Reactive - DisposeWith above all - without the caller converting it by hand. /// -/// -/// -/// The conversion is identity-stable: every conversion of the same container yields the same -/// , and the container owns that composite, so anything registered through it -/// is disposed when the container is. Registering after the container is disposed disposes the registration -/// immediately, matching . -/// -/// -/// Registrations made through the composite are not visible to the container's own -/// members: the composite occupies a single slot, so Count counts it once -/// and Contains/Remove do not see through it. -/// -/// +/// Conversions reuse a composite owned by the container. Composite registrations occupy one container slot and are not individually visible through Count, Contains, or Remove. [System.Diagnostics.DebuggerDisplay("ContainerDisposable: Count = {Count}, IsDisposed = {IsDisposed}")] public sealed class ContainerDisposable : MultipleDisposable { @@ -79,9 +67,7 @@ public CompositeDisposable ToCompositeDisposable() { lock (_gate) { - // A disposed composite is still the right answer once the container itself is disposed - it is the - // sink that disposes late arrivals. After Clear() or Remove() the container lives on, so a composite - // it disposed has to be replaced rather than handed out again. + // Disposed containers reject late additions; live containers replace composites removed by Clear or Remove. var existing = _composite; if (existing is not null && (!existing.IsDisposed || IsDisposed)) { @@ -91,9 +77,7 @@ public CompositeDisposable ToCompositeDisposable() var created = new CompositeDisposable(); _composite = created; - // Registering the composite with the container is what ties the two lifetimes together. On an - // already-disposed container this disposes the composite instead, which is what a caller adding to - // a disposed container should get. + // Register the composite so its lifetime follows the container. Add(created); return created; } @@ -104,9 +88,7 @@ protected override void Dispose(bool disposing) { base.Dispose(disposing); - // The composite occupies a slot in the container, so the base disposed it just now - or Clear()/Remove() - // did on the way out. Disposing it here is idempotent and states the ownership outright. Nothing in this - // hierarchy has a finalizer and the class is sealed, so this only ever runs on the deterministic path. + // The container owns the composite; repeated disposal is harmless. _composite?.Dispose(); } } diff --git a/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs b/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs index b9e73a39..56783db4 100644 --- a/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs +++ b/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs @@ -19,13 +19,7 @@ public static partial class LinqExtensions /// The container. /// The original disposable. /// is . - /// - /// A converts to a System.Reactive CompositeDisposable, so - /// without this overload a call site that imports both this namespace and System.Reactive's fluent - /// disposal helpers has two equally-good candidates - the inherited - /// DisposeWith(MultipleDisposable) and System.Reactive's DisposeWith(CompositeDisposable) - /// - and is ambiguous. Taking the container exactly makes this an identity match, which wins outright. - /// + /// This exact container overload resolves DisposeWith calls when System.Reactive's composite overload is also in scope. public T DisposeWith(ContainerDisposable disposables) { ArgumentExceptionHelper.ThrowIfNull(disposables); diff --git a/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs b/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs index bc20bcf3..de0d9f10 100644 --- a/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs +++ b/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs @@ -17,10 +17,7 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class HandlerSequencer : CoalescingDispatchScheduler { - /// - /// Cached runnable wrapping the drain. The drain callback is invariant for the lifetime of the sequencer, - /// so the JNI runnable bridge is built once and reused for every posted batch rather than per post. - /// + /// JNI drain callback reused across posted batches. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "SST1422:Move this field into the method that uses it", diff --git a/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs b/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs index 9ba8aed4..eff3ce0a 100644 --- a/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs +++ b/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs @@ -21,10 +21,7 @@ public sealed class NSRunloopSequencer : CoalescingDispatchScheduler /// Nanoseconds per millisecond, used to convert a managed delay into a offset. private const long NanosecondsPerMillisecond = 1_000_000; - /// - /// Cached dispatch block wrapping the drain. The drain callback is invariant for the lifetime of the - /// sequencer, so the block is created once and re-enqueued for every posted batch rather than per post. - /// + /// Native drain callback reused across posted batches. private DispatchBlock? _drainBlock; /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs index b0a31c20..58638557 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs @@ -58,6 +58,7 @@ protected override bool Post(Action drain) } /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) { var timer = DispatcherQueue.CreateTimer(); diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt index cdd92cd7..0e98b297 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt @@ -21,6 +21,7 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Reactive.Co public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } public static class DispatcherQueueSequencerExtensions diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt index cdd92cd7..0e98b297 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt @@ -21,6 +21,7 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Reactive.Co public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } public static class DispatcherQueueSequencerExtensions diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt index cdd92cd7..0e98b297 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt @@ -21,6 +21,7 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Reactive.Co public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } public static class DispatcherQueueSequencerExtensions diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt index cdd92cd7..0e98b297 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt @@ -21,6 +21,7 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Reactive.Co public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } public static class DispatcherQueueSequencerExtensions diff --git a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs index e355b457..eae31d1f 100644 --- a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs @@ -57,7 +57,7 @@ public DispatcherQueueSequencer(DispatcherQueue dispatcherQueue, DispatcherQueue /// Gets the debugger display text. [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] - private string DebuggerDisplay => ToString() ?? string.Empty; + internal string DebuggerDisplay => ToString() ?? string.Empty; /// [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -85,6 +85,7 @@ private bool Post(Action drain) /// Runs delayed work on a dispatcher queue timer so it executes directly on the dispatcher thread. /// Work item to execute at the due time. /// Absolute monotonic timestamp at which to execute the item. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void ScheduleDelayed(IWorkItem item, long dueTimestamp) { var timer = DispatcherQueue.CreateTimer(); diff --git a/src/ReactiveUI.Primitives.WinUI/Properties/AssemblyInfo.cs b/src/ReactiveUI.Primitives.WinUI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..f20e0b73 --- /dev/null +++ b/src/ReactiveUI.Primitives.WinUI/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ReactiveUI.Primitives.WinUI.Tests")] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs index dca228a7..3c262918 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs @@ -44,6 +44,7 @@ protected override bool Post(Action drain) } /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) { DispatcherTimer timer = new(Priority, Dispatcher) { Interval = dueTime }; diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt index 2402cbe7..bd7a95e0 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt @@ -21,5 +21,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt index 2402cbe7..bd7a95e0 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt @@ -21,5 +21,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt index cad7393f..d8bf2565 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt @@ -20,5 +20,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt index cad7393f..d8bf2565 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt @@ -20,5 +20,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt index cad7393f..d8bf2565 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt @@ -20,5 +20,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt index cad7393f..d8bf2565 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt @@ -20,5 +20,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt index 2402cbe7..bd7a95e0 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt @@ -21,5 +21,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt index 2402cbe7..bd7a95e0 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt @@ -21,5 +21,6 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Reactive.Concurr public System.Windows.Threading.Dispatcher Dispatcher { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } protected override bool Post(System.Action drain) { } + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] protected override System.IDisposable ScheduleOnDispatcher(System.Action work, System.TimeSpan dueTime) { } } diff --git a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index d03c06f8..9047b599 100644 --- a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -49,7 +49,7 @@ public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) /// Gets the debugger display text. [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] - private string DebuggerDisplay => ToString() ?? string.Empty; + internal string DebuggerDisplay => ToString() ?? string.Empty; /// [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -71,6 +71,7 @@ private bool Post(Action drain) /// Runs delayed work on a dispatcher timer so it executes directly on the dispatcher thread. /// Work item to execute at the due time. /// Absolute monotonic timestamp at which to execute the item. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void ScheduleDelayed(IWorkItem item, long dueTimestamp) { DispatcherTimer timer = diff --git a/src/ReactiveUI.Primitives.Wpf/Properties/AssemblyInfo.cs b/src/ReactiveUI.Primitives.Wpf/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..f9cae6c8 --- /dev/null +++ b/src/ReactiveUI.Primitives.Wpf/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ReactiveUI.Primitives.Wpf.Tests")] diff --git a/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs b/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs index 1493e25b..2fb26692 100644 --- a/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs +++ b/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs @@ -37,6 +37,9 @@ public record struct DispatchSequencerState /// Optional platform delayed-scheduling override; uses the shared thread-pool timer. private readonly Action? _scheduleDelayed; + /// Schedules delayed work when the platform provides no override. + private readonly ISequencer _sharedTimer; + /// Approximate number of ready items; snapshots a drain batch. private int _readyCount; @@ -62,12 +65,29 @@ public DispatchSequencerState( Func post, Action drain, Action? scheduleDelayed) + : this(owner, post, drain, scheduleDelayed, ThreadPoolSequencer.Instance) + { + } + + /// Initializes a new instance of the struct. + /// The owning sequencer. + /// Posts the cached drain. + /// The cached drain callback. + /// The optional platform delay override. + /// Schedules work without a platform delay override. + internal DispatchSequencerState( + ISequencer owner, + Func post, + Action drain, + Action? scheduleDelayed, + ISequencer sharedTimer) { _ready = new(); _owner = owner; _post = post; _drain = drain; _scheduleDelayed = scheduleDelayed; + _sharedTimer = sharedTimer; } /// Gets the sequencer's notion of current time. @@ -188,11 +208,7 @@ public void RunDrain() } } - /// Cancels and drops every ready work item, disposing each so its caller's handle is released. - /// - /// Only calls this: it owns the timer that is its dispatcher, so disposal leaves - /// nothing able to drain the queue, whereas the platform dispatchers outlive the sequencer and keep draining. - /// + /// Releases queued items when the sequencer owns its dispatcher lifetime. internal void ReleaseQueued() { while (_ready.TryDequeue(out var item)) @@ -205,21 +221,17 @@ internal void ReleaseQueued() } } - // Only the live shared thread-pool timer coming due reaches this path, so coverage excludes it. /// Parks delayed work on the shared thread-pool timer, which marshals it back to the dispatcher when due. /// Work item to execute once due. /// Absolute monotonic timestamp at which to execute the item. [MethodImpl(MethodImplOptions.AggressiveInlining)] - [ExcludeFromCodeCoverage] private readonly void ScheduleOnSharedTimer(IWorkItem item, long dueTimestamp) => - ThreadPoolSequencer.Instance.Schedule(new MarshalOnDueWorkItem(_owner, item), dueTimestamp); + _sharedTimer.Schedule(new MarshalOnDueWorkItem(_owner, item), dueTimestamp); - // Constructed and run only by the shared thread-pool timer path, so coverage excludes it too. /// Work item used by the shared timer path to marshal delayed work back to the dispatcher. /// Owning dispatch sequencer. /// Work item to marshal. - [ExcludeFromCodeCoverage] - private sealed class MarshalOnDueWorkItem(ISequencer owner, IWorkItem item) : IWorkItem + internal sealed class MarshalOnDueWorkItem(ISequencer owner, IWorkItem item) : IWorkItem { /// Owning dispatch sequencer. private readonly ISequencer _owner = owner; diff --git a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs index e5b8b8fa..a609bb3a 100644 --- a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs @@ -110,11 +110,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) { SetRunning(true); - var dueTime = Sequencer.TimeUntil(dueTimestamp); - if (dueTime > TimeSpan.Zero) - { - Thread.Sleep(dueTime); - } + WaitIfNeeded(Sequencer.TimeUntil(dueTimestamp), Wait); try { @@ -173,6 +169,19 @@ public void Schedule(IWorkItem item, long dueTimestamp) queue.Enqueue(si); } + /// Waits only when work remains in the future. + /// The remaining delay. + /// The wait operation. + internal static void WaitIfNeeded(TimeSpan dueTime, Action wait) + { + if (dueTime <= TimeSpan.Zero) + { + return; + } + + wait(dueTime); + } + /// Gets the queued recursive work for the current thread. /// The current thread queue, if one exists. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -186,12 +195,25 @@ public void Schedule(IWorkItem item, long dueTimestamp) /// Value indicating whether work is running. private static void SetRunning(bool running) => _running = running; + /// Blocks the scheduling thread until delayed work becomes due. + /// The remaining delay. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Wait(TimeSpan dueTime) => Thread.Sleep(dueTime); + /// Runs queued current-thread work. - private static class Trampoline + internal static class Trampoline { /// Runs all work currently in the queue. /// Queue to drain. - public static void Run(SequencerQueue queue) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void Run(SequencerQueue queue) => Run(queue, static () => Sequencer.Timestamp, Wait); + + /// Drains work using the supplied clock and wait operation. + /// The pending work. + /// The monotonic clock. + /// The wait operation. + internal static void Run(SequencerQueue queue, Func timestamp, Action wait) { while (queue.Count > 0) { @@ -201,11 +223,7 @@ public static void Run(SequencerQueue queue) continue; } - var wait = Sequencer.TimeUntil(item.DueTime); - if (wait > TimeSpan.Zero) - { - Thread.Sleep(wait); - } + WaitIfNeeded(Sequencer.TimeUntil(item.DueTime, timestamp()), wait); if (!item.IsDisposed) { @@ -216,7 +234,7 @@ public static void Run(SequencerQueue queue) } /// Cancellable action work item. - private sealed class ActionWorkItem : IWorkItem, IsDisposed + internal sealed class ActionWorkItem : IWorkItem, IsDisposed { /// Action to execute. private readonly Action _action; diff --git a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs index f17d94db..b02cc01e 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs @@ -68,10 +68,18 @@ public void Schedule(IWorkItem item, long dueTimestamp) { ArgumentExceptionHelper.ThrowIfNull(item); - var dueTime = Sequencer.TimeUntil(dueTimestamp); + RunScheduled(item, Sequencer.TimeUntil(dueTimestamp), Wait); + } + + /// Waits for a relative delay and executes work that remains active. + /// The scheduled work. + /// The remaining delay. + /// The blocking wait operation. + internal static void RunScheduled(IWorkItem item, TimeSpan dueTime, Action wait) + { if (dueTime.Ticks > 0) { - Thread.Sleep(dueTime); + wait(dueTime); } if (Sequencer.IsCancelled(item)) @@ -81,4 +89,10 @@ public void Schedule(IWorkItem item, long dueTimestamp) item.Execute(); } + + /// Blocks the scheduling thread for the remaining delay. + /// The remaining delay. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static void Wait(TimeSpan dueTime) => Thread.Sleep(dueTime); } diff --git a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs index b3a8c1f1..1e5f1e32 100644 --- a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs @@ -45,9 +45,16 @@ internal static long AddTimestamp(long timestamp, TimeSpan dueTime) /// Calculates the remaining wall time until a monotonic timestamp. /// Absolute monotonic timestamp. /// The remaining time until . - internal static TimeSpan TimeUntil(long dueTimestamp) + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + internal static TimeSpan TimeUntil(long dueTimestamp) => TimeUntil(dueTimestamp, Timestamp); + + /// Calculates a delay from an explicit monotonic clock reading. + /// The absolute due timestamp. + /// The current clock reading. + /// The nonnegative remaining delay. + internal static TimeSpan TimeUntil(long dueTimestamp, long timestamp) { - var delta = dueTimestamp - Timestamp; + var delta = dueTimestamp - timestamp; return delta <= 0 ? TimeSpan.Zero : TimeSpan.FromSeconds(delta / (double)System.Diagnostics.Stopwatch.Frequency); diff --git a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs index cc5161d5..b56f57ff 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs @@ -9,17 +9,31 @@ namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class SynchronizationContextSequencer : ISequencer { + /// Schedules delayed marshal callbacks. + private readonly ISequencer _delaySequencer; + /// Initializes a new instance of the class. /// The synchronization context used to schedule work. /// is . - public SynchronizationContextSequencer(SynchronizationContext context) => + public SynchronizationContextSequencer(SynchronizationContext context) + : this(context, ThreadPoolSequencer.Instance) + { + } + + /// Initializes a new instance of the class. + /// The context receiving ready work. + /// The scheduler delivering delayed callbacks. + /// The synchronization context is null. + internal SynchronizationContextSequencer(SynchronizationContext context, ISequencer delaySequencer) + { Context = context ?? throw new ArgumentNullException(nameof(context)); + _delaySequencer = delaySequencer; + } /// Gets a sequencer for the current synchronization context. /// There is no current synchronization context. public static SynchronizationContextSequencer Current { - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] get => new(SynchronizationContext.Current ?? throw new InvalidOperationException("There is no current synchronization context.")); } @@ -57,7 +71,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) return; } - ThreadPoolSequencer.Instance.Schedule(new DelayedPostWorkItem(this, item), dueTimestamp); + _delaySequencer.Schedule(new DelayedPostWorkItem(this, item), dueTimestamp); } /// Executes the work item unless it has been cancelled. diff --git a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs index cd26e04d..49341509 100644 --- a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; namespace ReactiveUI.Primitives.Concurrency; @@ -15,11 +14,26 @@ public sealed class TaskPoolSequencer : ISequencer /// Task factory used to schedule asynchronous work. private readonly TaskFactory _taskFactory; + /// Schedules delayed dispatch callbacks. + private readonly ISequencer _delaySequencer; + /// Initializes a new instance of the class. /// The task factory. /// is . - public TaskPoolSequencer(TaskFactory taskFactory) => + public TaskPoolSequencer(TaskFactory taskFactory) + : this(taskFactory, ThreadPoolSequencer.Instance) + { + } + + /// Initializes a new instance of the class. + /// The factory dispatching ready work. + /// The scheduler delivering delayed callbacks. + /// The task factory is null. + internal TaskPoolSequencer(TaskFactory taskFactory, ISequencer delaySequencer) + { _taskFactory = taskFactory ?? throw new ArgumentNullException(nameof(taskFactory)); + _delaySequencer = delaySequencer; + } /// Gets the shared sequencer backed by . public static TaskPoolSequencer Instance { get; } = new(Task.Factory); @@ -70,7 +84,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) return; } - ThreadPoolSequencer.Instance.Schedule(new DelayedDispatchWorkItem(this, item), dueTimestamp); + _delaySequencer.Schedule(new DelayedDispatchWorkItem(this, item), dueTimestamp); } /// Executes a work item and routes unhandled exceptions. @@ -95,7 +109,7 @@ private void Execute(IWorkItem item) return; } - ExceptionDispatchInfo.Capture(ex).Throw(); + throw; } } diff --git a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs index defeae76..f18b34f6 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs @@ -26,12 +26,18 @@ public sealed class ThreadPoolSequencer : ISequencer, IDisposable private readonly PriorityQueue _queue = new(); /// Single timer owned by the sequencer for all delayed work. - private readonly Timer _timer; + private readonly Timer? _timer; - /// - /// Non-zero once has released the timer and the queue; written under so - /// timer paths are ordered against disposal, and read unlocked on the immediate path, which ignores the timer. - /// + /// Reads the monotonic clock used by the delay queue. + private readonly Func _timestamp; + + /// Queues an immediate callback. + private readonly Action _queueImmediate; + + /// Updates the delay timer. + private readonly Action _changeTimer; + + /// Non-zero after disposal; writes hold the gate, and immediate scheduling reads without it. private int _isDisposed; /// Initializes a new instance of the class; callers use . @@ -40,18 +46,37 @@ public sealed class ThreadPoolSequencer : ISequencer, IDisposable "SST2403:Do not let 'this' escape from a constructor", Justification = "The timer is created disarmed, so nothing can call back into it until Schedule arms it after construction.")] - internal ThreadPoolSequencer() => + internal ThreadPoolSequencer() + { _timer = new( static state => ((ThreadPoolSequencer)state!).RunDue(), this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timestamp = static () => Sequencer.Timestamp; + _queueImmediate = QueueOnThreadPool; + _changeTimer = ChangeTimer; + } + + /// Initializes a new instance of the class. + /// Reads the monotonic clock. + /// Queues an immediate callback. + /// Updates the delay timer. + internal ThreadPoolSequencer( + Func timestamp, + Action queueImmediate, + Action changeTimer) + { + _timestamp = timestamp; + _queueImmediate = queueImmediate; + _changeTimer = changeTimer; + } /// Gets the scheduler's notion of current time. public DateTimeOffset Now => Sequencer.Now; /// Gets the scheduler's monotonic timestamp. - public long Timestamp => Sequencer.Timestamp; + public long Timestamp => _timestamp(); /// Gets the debugger display text. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] @@ -70,7 +95,7 @@ public void Schedule(IWorkItem item) ArgumentExceptionHelper.ThrowIfNull(item); ObjectDisposedExceptionHelper.ThrowIf(IsDisposed, this); - _ = ThreadPool.UnsafeQueueUserWorkItem(ImmediateCallback, item); + _queueImmediate(ImmediateCallback, item); } /// Schedules a work item to be executed through the thread pool at a monotonic timestamp. @@ -90,8 +115,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) lock (_gate) { - // Checked under the gate disposal takes, so an item that reaches the queue is one disposal will see and - // release; it can never land behind a released timer. + // Queue under the disposal gate so accepted items are released during teardown. ObjectDisposedExceptionHelper.ThrowIf(IsDisposed, this); _queue.Enqueue(new(item, dueTimestamp)); @@ -106,9 +130,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) /// public void Dispose() { - // Arming the timer takes this gate too, so it cannot be re-armed after the release below. Timer.Dispose does - // not wait for an in-flight callback, so a drain blocked on the gate observes the disposed flag rather than - // deadlocking here. + // The gate prevents rearming after disposal; timer disposal does not wait for callbacks. lock (_gate) { if (IsDisposed) @@ -117,25 +139,13 @@ public void Dispose() } Volatile.Write(ref _isDisposed, 1); - _timer.Dispose(); + _timer?.Dispose(); ReleaseQueuedNoLock(); } } - /// Executes the work item unless it has been cancelled. - /// Work item to execute. - private static void ExecuteQueued(IWorkItem item) - { - if (Sequencer.IsCancelled(item)) - { - return; - } - - item.Execute(); - } - /// Runs due delayed work. - private void RunDue() + internal void RunDue() { while (true) { @@ -153,6 +163,26 @@ private void RunDue() } } + /// Queues the runtime callback on the thread pool. + /// The callback to queue. + /// The callback state. + [ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void QueueOnThreadPool(WaitCallback callback, object state) => + ThreadPool.UnsafeQueueUserWorkItem(callback, state); + + /// Executes the work item unless it has been cancelled. + /// Work item to execute. + private static void ExecuteQueued(IWorkItem item) + { + if (Sequencer.IsCancelled(item)) + { + return; + } + + item.Execute(); + } + /// Attempts to dequeue the next due item. /// The dequeued item. /// when an item was dequeued. @@ -198,8 +228,7 @@ private void ArmTimerNoLock() { if (IsDisposed) { - // Disposal released the timer and the queue under this same gate, so a drain unwinding on the timer's - // callback thread lands here and must not re-arm a released timer. + // A callback unwinding after disposal must not rearm the timer. return; } @@ -210,13 +239,19 @@ private void ArmTimerNoLock() if (_queue.Count == 0) { - _ = _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _changeTimer(Timeout.InfiniteTimeSpan); return; } - _ = _timer.Change(Sequencer.TimeUntil(_queue.Peek().DueTimestamp), Timeout.InfiniteTimeSpan); + _changeTimer(Sequencer.TimeUntil(_queue.Peek().DueTimestamp, Timestamp)); } + /// Arms the runtime delay timer. + /// The remaining delay. + [ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ChangeTimer(TimeSpan dueTime) => _timer!.Change(dueTime, Timeout.InfiniteTimeSpan); + /// Delayed thread-pool work item queued in the sequencer heap. internal readonly struct TimedWorkItem : IComparable, IEquatable { @@ -252,10 +287,7 @@ public override int GetHashCode() => unchecked((RuntimeHelpers.GetHashCode(Item) * 397) ^ DueTimestamp.GetHashCode()); } - /// - /// Stateful work item carrying the scheduled state and the sequencer handed back to the action, with the - /// run/cancel handshake that lets a cancellation arriving mid-run still release whatever the action returned. - /// + /// Runs an action with its state and releases its result if canceled during execution. /// The scheduled state type. /// The owning sequencer. /// The scheduled state. @@ -312,6 +344,17 @@ public void Dispose() internal void Queue(TimeSpan dueTime) => _owner.Schedule(this, Sequencer.AddTimestamp(_owner.Timestamp, dueTime)); + /// Releases a published result if cancellation owns the work item. + internal void ReleaseCanceledResult() + { + if (!IsDisposed) + { + return; + } + + Interlocked.Exchange(ref _disposable, EmptyDisposable.Instance)?.Dispose(); + } + /// Runs scheduled work. private void Run() { @@ -328,12 +371,7 @@ private void Run() return; } - if (!IsDisposed) - { - return; - } - - disposable.Dispose(); + ReleaseCanceledResult(); } } } diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs index 19e850c9..f502c324 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs @@ -10,13 +10,7 @@ namespace ReactiveUI.Primitives.Concurrency; -/// Builds the exception messages the virtual-time sequencers throw. -/// -/// The parsed format sits on a non-generic type on purpose. A static field inside -/// is a field of each closed generic, so the format would be -/// parsed once per type-argument pair; one shared instance serves every one of them. Frameworks without -/// CompositeFormat fall back to formatting the literal. -/// +/// Shares virtual-time message formats across clock types. internal static class VirtualTimeMessages { /// Format of the message thrown when the clock is advanced while the scheduler is already running. diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs index 0750f8b5..54220103 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Concurrency; -/// -/// Mutable state and mechanics backing the virtual-time sequencers. A single sequencer owns one of these inline -/// and forwards its public surface here, so the virtual-time logic lives in one place without inheritance or -/// composition between the sequencer types. Per-clock arithmetic is supplied as delegates rather than overrides. -/// +/// Stores virtual-time state with delegate-supplied clock arithmetic. /// Absolute time representation type. /// Relative time representation type. [System.Diagnostics.CodeAnalysis.SuppressMessage( @@ -306,8 +302,7 @@ internal readonly IDisposable ScheduleAbsolute( { ArgumentExceptionHelper.ThrowIfNull(action); - // Copy the reference-typed queue into a local so the self-removal closure synchronizes through that - // reference rather than capturing the enclosing struct's "this" (not permitted for struct members). + // Capture the queue reference; a struct member cannot capture this in a closure. var queue = _queue; ScheduledItem si = new(dueTime, _comparer, self => diff --git a/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs index 26897a66..23a1f68d 100644 --- a/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs @@ -9,39 +9,32 @@ namespace ReactiveUI.Primitives.Concurrency; /// -/// Task-pool replacement for single-threaded event-loop runtimes such as browser WebAssembly: it never starts -/// threads and never blocks. Immediate work is batched one drain per event-loop turn through a zero-due timer -/// (a setTimeout(0) macrotask on WebAssembly, so the browser can render between batches); delayed work -/// uses the shared timer, which the WebAssembly runtime backs with the JS event loop. +/// Schedules batches on a single-threaded event loop without blocking or starting threads. +/// Delayed work uses the shared timer; immediate batches yield between event-loop turns. /// /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class WasmSequencer : ISequencer, IDisposable { - /// - /// Guards the drain timer. Every arm of the timer goes through , which takes this gate, and - /// releases the timer while holding it — so the timer can never be armed after it is gone. - /// + /// Serializes timer arming and disposal. private readonly Lock _gate = new(); /// One-shot timer used to yield a drain to the event loop. - private readonly Timer _timer; + private readonly Timer? _timer; + + /// Posts a drain to the event loop. + private readonly Func _postDrain; + + /// Schedules the delayed marshal callback. + private readonly Action _scheduleDelayed; /// Coalescing dispatch engine. private DispatchSequencerState _state; - /// - /// Non-zero once has released the drain timer and the ready queue. Written under - /// so every path that touches the timer is ordered against disposal, but read without it - /// on the scheduling paths, which re-check it after enqueueing rather than holding the gate across a queue. - /// + /// Non-zero after disposal releases the timer and queue; timer access is serialized by the gate. private int _isDisposed; - /// - /// Initializes a new instance of the class. Callers use ; this is - /// internal so a test can own an isolated sequencer it may dispose without shutting the shared singleton down for - /// every other test. - /// + /// Initializes a new instance of the class. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Correctness", "SST2403:Do not let 'this' escape from a constructor", @@ -54,6 +47,18 @@ internal WasmSequencer() this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _postDrain = ArmDrainTimer; + _scheduleDelayed = ThreadPoolSequencer.Instance.Schedule; + _state = new(this, Post, RunDrain, ScheduleDelayed); + } + + /// Initializes a new instance of the class. + /// Posts a drain to the event loop. + /// Schedules the delayed marshal callback. + internal WasmSequencer(Func postDrain, Action scheduleDelayed) + { + _postDrain = postDrain; + _scheduleDelayed = scheduleDelayed; _state = new(this, Post, RunDrain, ScheduleDelayed); } @@ -94,16 +99,12 @@ public void Schedule(IWorkItem item, long dueTimestamp) } /// - /// Releases the drain timer this sequencer owns and cancels the ready work still queued behind it. Scheduling - /// through a disposed sequencer throws rather than queueing work no drain - /// will ever reach. Delayed work still parked on the shared timer is released when it comes due, because the - /// caller cancels it through the handle it was given rather than through this sequencer. + /// Releases the drain timer and cancels queued work. Further scheduling throws. + /// Delayed work on the shared timer is released when due unless its caller cancels it first. /// public void Dispose() { - // Under the gate: every arm of the timer takes it too, so the timer can never be re-armed after it is - // released here. Timer.Dispose does not wait for an in-flight callback, so a drain blocked on the gate - // inside Post cannot deadlock this — it simply observes the disposed flag once it gets in, and backs off. + // Timer arming and disposal share the gate; disposal does not wait for active callbacks. lock (_gate) { if (IsDisposed) @@ -112,18 +113,13 @@ public void Dispose() } Volatile.Write(ref _isDisposed, 1); - _timer.Dispose(); + _timer?.Dispose(); } _state.ReleaseQueued(); } - /// - /// Enqueues ready work onto the drain without the disposed guard, releasing it again when a disposal raced the - /// enqueue. Internal rather than private so a test can drive the enqueue that was already past - /// 's disposed check when the disposal drained the ready queue, and prove the - /// item is handed back rather than stranded. - /// + /// Queues ready work and releases it if disposal overlaps the enqueue. /// Work item to execute on the next event-loop turn. internal void ScheduleReady(IWorkItem item) { @@ -132,32 +128,30 @@ internal void ScheduleReady(IWorkItem item) } /// Arms the drain timer to fire on the next event-loop turn. - /// - /// Ignored. The parameter exists only because posts through a - /// of ; the drain callback is already carried by the timer's state. - /// + /// The callback to post. /// when the timer accepted the change. - private bool Post(Action _) + private bool Post(Action drain) { lock (_gate) { - // Arming a released timer is a silent no-op that would leave the drain latch set on a drain that can - // never run. Refusing the post instead lets the engine hand the latch straight back; with the ready - // queue released and scheduling closed, there is nothing left for that drain to do anyway. - return !IsDisposed && _timer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + // Reject posts after disposal so the drain claim is released. + return !IsDisposed && _postDrain(drain); } } - /// - /// Marshals delayed work back onto this sequencer's drain once the shared timer says it is due. This replaces the - /// engine's default marshal step, which would call back through and throw - /// on the timer's thread for an item that came due after disposal. - /// + /// Returns due work to the drain, releasing it if the sequencer is disposed. /// Work item to run once it is due. /// Absolute monotonic timestamp at which to execute the item. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ScheduleDelayed(IWorkItem item, long dueTimestamp) => - ThreadPoolSequencer.Instance.Schedule(new DelayedWorkItem(this, item), dueTimestamp); + _scheduleDelayed(new DelayedWorkItem(this, item), dueTimestamp); + + /// Arms the runtime drain timer. + /// The cached callback carried by the timer. + /// Whether the timer accepted the callback. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool ArmDrainTimer(Action drain) => _timer!.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); /// Forwards the cached drain callback to the engine. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -174,11 +168,7 @@ private void ReleaseQueuedIfDisposed() _state.ReleaseQueued(); } - /// - /// Delayed work held by the shared timer until it comes due, then marshalled onto the owner's drain. A sequencer - /// disposed while this waits can no longer drain anything, so the item is released to its caller instead of being - /// pushed into a queue that will never move again. - /// + /// Requeues work when due, or releases it if the sequencer is disposed. /// The sequencer whose drain runs the item. /// The work item to marshal. private sealed class DelayedWorkItem(WasmSequencer owner, IWorkItem item) : IWorkItem diff --git a/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs b/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs index 4a2bcb30..d3611a72 100644 --- a/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs +++ b/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs @@ -20,10 +20,7 @@ public sealed class HandlerSequencer : ISequencer /// Coalescing dispatch engine. private DispatchSequencerState _state; - /// - /// Cached runnable wrapping the drain. The drain callback is invariant for the lifetime of the sequencer, - /// so the JNI runnable bridge is built once and reused for every posted batch rather than per post. - /// + /// JNI drain callback reused across posted batches. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "SST1422:Move this field into the method that uses it", diff --git a/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs b/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs index 94037799..4a9b5ef1 100644 --- a/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs +++ b/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs @@ -23,10 +23,7 @@ public sealed class NSRunloopSequencer : ISequencer /// Coalescing dispatch engine. private DispatchSequencerState _state; - /// - /// Cached dispatch block wrapping the drain. The drain callback is invariant for the lifetime of the - /// sequencer, so the block is created once and re-enqueued for every posted batch rather than per post. - /// + /// Native drain callback reused across posted batches. private DispatchBlock? _drainBlock; /// Initializes a new instance of the class. diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AllocationProbe.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AllocationProbe.cs index a30ef906..d6392f13 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AllocationProbe.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AllocationProbe.cs @@ -10,13 +10,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Harness-free allocation probe (--alloc). Measures the exact bytes allocated per operation -/// with , reusing a single observer so the -/// reported figure is the operator's own allocation -- not the per-op test observer that -/// BenchmarkDotNet's Allocated column folds in. Run with: -/// dotnet run -c Release --project ... -- --alloc. -/// +/// Measures per-operation allocation with a shared observer for the --alloc command. internal static class AllocationProbe { /// Shared single-character payload reused by reference/type-coercion probes. @@ -107,7 +101,7 @@ internal static void Run() ProbePassThroughTerminal(observer, sparkObserver, intervalObserver, listObserver, arrayObserver); Section("Coverage-gap operators / factories"); - ProbeCoverageGap(observer, listObserver, stringObserver, failSource); + ProbeOperatorsAndFactories(observer, listObserver, stringObserver, failSource); // Calm / Shift / DelayStart need time advancement; their allocation is captured by the // OperatorTimeSchedulerBenchmarks BDN "Allocated" column instead of this synchronous probe. @@ -212,12 +206,12 @@ private static void ProbePassThroughTerminal( Row("CollectArray (range)", () => Signal.Sequence(0, Count).CollectArray().Subscribe(arrayObserver).Dispose()); } - /// Probes coverage-gap operator and factory allocation. + /// Measures operator and factory allocations per subscription. /// The reused integer observer. /// The reused list observer. /// The reused string observer. /// The shared failing source. - private static void ProbeCoverageGap( + private static void ProbeOperatorsAndFactories( IntSignalWitness observer, CountingSignalWitness> listObserver, CountingSignalWitness stringObserver, diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ConnectableMulticastBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ConnectableMulticastBenchmarks.cs index c1759fb3..1f071301 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ConnectableMulticastBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ConnectableMulticastBenchmarks.cs @@ -11,10 +11,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks the general multicast primitive (a connectable backed by a caller-supplied hub), -/// which underlies the Publish/Share family. -/// +/// Benchmarks the general multicast primitive (a connectable backed by a caller-supplied hub), which underlies the Publish/Share family. [MemoryDiagnoser] public class ConnectableMulticastBenchmarks { diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryStateTimerBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryStateTimerBenchmarks.cs index adb9d8a2..5abf8532 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryStateTimerBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryStateTimerBenchmarks.cs @@ -15,11 +15,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks the previously-uncovered factories: the closure-free CreateWithState against -/// closure-based Create, the Iterate generator, the Every/After timer -/// factories under virtual time, and the FromEventPattern event bridge. -/// +/// Benchmarks stateful factories, generators, virtual timers, and event subscriptions. [MemoryDiagnoser] [System.Diagnostics.DebuggerDisplay("FactoryStateTimerBenchmarks: Limit = {_limit}")] public class FactoryStateTimerBenchmarks diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorCombineLatestBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorCombineLatestBenchmarks.cs index 0cc853d6..5dfdb760 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorCombineLatestBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorCombineLatestBenchmarks.cs @@ -9,12 +9,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks latest-value combination across the shapes that reach different coordinators: the pairwise -/// coordinator holds both values in typed fields, while three or more sources share one coordinator that keeps -/// latest values in an object?[]. The tuple and list overloads sit on top of those, so their cases show -/// what the result shape itself costs on each path. -/// +/// Benchmarks latest-value combination across pair, tuple, and list result shapes. [MemoryDiagnoser] public class OperatorCombineLatestBenchmarks { diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs index 861edf2d..52a64aa6 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs @@ -13,10 +13,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks for the pass-through and materialization operators converted to dedicated signals -/// (Tap, IgnoreValues, Spark/Unspark materialize round-trip, SubscribeOn, Reattempt). -/// +/// Benchmarks for the pass-through and materialization operators converted to dedicated signals (Tap, IgnoreValues, Spark/Unspark materialize round-trip, SubscribeOn, Reattempt). [MemoryDiagnoser] public class OperatorPassThroughBenchmarks { diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulVariantBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulVariantBenchmarks.cs index c22b883c..3513c0fc 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulVariantBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulVariantBenchmarks.cs @@ -9,12 +9,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks the state-passing operator variants (MapWith / KeepWith / TapWith) against the -/// closure-capturing equivalents in System.Reactive and R3. The Primitives variants pass runtime -/// state explicitly with a cached static delegate, so they allocate no per-subscription closure; -/// the comparison frameworks must capture the same runtime value in a closure. -/// +/// Compares explicit-state operators with closure-based equivalents in System.Reactive and R3. [MemoryDiagnoser] [System.Diagnostics.DebuggerDisplay("OperatorStatefulVariantBenchmarks: Factor = {_factor}, Threshold = {_threshold}")] public class OperatorStatefulVariantBenchmarks diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTakeBufferRecoverBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTakeBufferRecoverBenchmarks.cs index 907a4e1d..31ed4acf 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTakeBufferRecoverBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTakeBufferRecoverBenchmarks.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks for the common, previously-uncovered operators: prefix truncation (Take), batching -/// (Buffer / Chunk), and the error-handling path (Recover / Resume vs Catch). -/// +/// Benchmarks prefix truncation, batching, and error recovery across observable libraries. [MemoryDiagnoser] public class OperatorTakeBufferRecoverBenchmarks { diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs index 1bb811b2..e123f901 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs @@ -235,9 +235,7 @@ private static IObservable ThrowInt(ExtensionsLibrary library) => [MethodImpl(MethodImplOptions.AggressiveInlining)] private static IObservable ThrowPrimitiveUnit() => Signal.Fail(Boom); - /// - /// Creates the generated even-digit regex. - /// + /// Creates the generated even-digit regex. /// The generated regex instance. [GeneratedRegex("^[02468]$")] private static partial Regex EvenRegex(); diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeBenchmarkRunner.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeBenchmarkRunner.cs index d3284d5b..df1433b9 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeBenchmarkRunner.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeBenchmarkRunner.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Runs every benchmark scenario once and writes each result as a key=value row, so the -/// libraries can be compared for parity by . Row order is -/// significant: the validator groups consecutive rows that share a scenario name. -/// +/// Runs each scenario once and writes consecutive key=value rows for parity comparison. internal static class SmokeBenchmarkRunner { /// Runs the deterministic smoke benchmark scenarios and writes their results to the console. @@ -19,7 +15,7 @@ internal static async Task RunAsync() await RunOperatorSmokeAsync(); RunSubjectSmoke(); RunAsyncBridgeSmoke(); - await RunExpansionSmokeBenchmarksAsync(); + await RunOperatorAndFactorySmokeBenchmarksAsync(); RunCoreRuntimeSmokeBenchmarks(); } @@ -130,9 +126,9 @@ private static void RunAsyncBridgeSmoke() Console.WriteLine($"R3CompletedTaskBridge={taskBridge.R3CompletedTaskBridge()}"); } - /// Runs the expansion-coverage smoke benchmark scenarios and writes their results to the console. - /// A task that completes when all expansion smoke benchmarks have run. - private static async Task RunExpansionSmokeBenchmarksAsync() + /// Runs operator and factory smoke benchmarks and prints their results. + /// The asynchronous benchmark run. + private static async Task RunOperatorAndFactorySmokeBenchmarksAsync() { await RunFactoryAdapterExpansionSmokeAsync(); RunTimeSchedulerSmoke(); @@ -269,10 +265,7 @@ private static async Task RunTerminalCollectionSmokeAsync() Console.WriteLine($"R3AllContains={await terminalCollections.R3AllContains()}"); } - /// - /// Writes the synchronous array-collection smoke results from a non-async method so the - /// synchronous CollectArray benchmarks are measured without awaiting their async overloads. - /// + /// Writes the synchronous array-collection smoke results from a non-async method so the synchronous CollectArray benchmarks are measured without awaiting their async overloads. /// The terminal-collection benchmarks instance. private static void WriteSynchronousCollectArrayResults(TerminalCollectionBenchmarks terminalCollections) { diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeParityValidator.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeParityValidator.cs index c253ad07..1286e947 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeParityValidator.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SmokeParityValidator.cs @@ -7,12 +7,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Validates the key=value rows written by . Consecutive -/// rows that normalize to the same scenario form a group, and every library in a group must produce -/// the same value — except for the scheduling differences documented below, which are pinned to -/// their known values so a change in behaviour is caught rather than silently accepted. -/// +/// Validates consecutive scenario groups against cross-library results and explicit scheduling expectations. internal static class SmokeParityValidator { /// The expected Primitives value for the documented SwitchRanges scheduling difference. diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TerminalAsyncVariantBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TerminalAsyncVariantBenchmarks.cs index 9fa39900..ac6f1f35 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TerminalAsyncVariantBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TerminalAsyncVariantBenchmarks.cs @@ -9,10 +9,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks the async terminal variants (CollectListAsync, FirstOrDefaultAsync, -/// LastOrDefaultAsync) against their System.Reactive and R3 task-returning equivalents. -/// +/// Benchmarks the async terminal variants (CollectListAsync, FirstOrDefaultAsync, LastOrDefaultAsync) against their System.Reactive and R3 task-returning equivalents. [MemoryDiagnoser] public class TerminalAsyncVariantBenchmarks { diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TimerSlotBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TimerSlotBenchmarks.cs index 8e519bbc..1cf86334 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TimerSlotBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/TimerSlotBenchmarks.cs @@ -11,12 +11,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// -/// Benchmarks the timer slot every repeating operator arms once per window. The burst cases elsewhere schedule -/// a single timer for the whole run and so never show what re-arming costs; these advance the clock between -/// values, which closes each window and forces a fresh arm, which is the rate that matters for a debounce over -/// a slow-moving source or for a periodic tick. -/// +/// Measures timer rearming by advancing virtual time through one window per value. [MemoryDiagnoser] public class TimerSlotBenchmarks { diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/CorpusSize.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/CorpusSize.cs index 202e8012..452df936 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/CorpusSize.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/CorpusSize.cs @@ -5,7 +5,6 @@ namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks; /// How much event-bearing source the benchmarks put in front of the generator. -/// Public because BenchmarkDotNet reads it off a public benchmark parameter. public enum CorpusSize { /// One wrapped host, the shape a small view model has. diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventCorpus.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventCorpus.cs index 4dcd64fa..25452270 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventCorpus.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventCorpus.cs @@ -7,19 +7,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks; -/// Builds the consumer source the generator is measured against. -/// -/// -/// Every host carries one of each delegate shape the generator handles - the conventional sender and arguments -/// pair, a parameterless action, a single payload, a multi-parameter delegate that becomes a tuple, and a -/// task-returning handler - because the per-event work differs between them and a corpus of only the easy shape -/// would flatter the emitter. -/// -/// -/// One host per file, as real code is laid out. That is what makes the incremental cases mean what they claim: -/// editing one host has to be editing one file, or the measurement is of re-parsing the whole corpus instead. -/// -/// +/// Builds one event host per file, with every supported delegate shape. internal static class EventCorpus { /// The file name of the shared declarations every host file depends on. @@ -84,13 +72,9 @@ public static class StaticHost return files; } - /// Builds one host's file with an extra event on it. + /// Changes one host's events while preserving its activation signature. /// The host index. /// The file text. - /// - /// The edit that has to invalidate exactly one wrapper: the activation overload's signature is untouched, every - /// other host is untouched, and only this host's own generated file has anything new to say. - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static string HostSourceWithAddedEvent(int index) => HostSource(index, true); diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs index 55f2ac8b..e89003d2 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs @@ -11,33 +11,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks; /// What the observable-event generator itself costs on a build and on a keystroke. -/// -/// -/// These run the generator only. RunGeneratorsAndUpdateCompilation would also fold the generated trees back -/// into a new compilation, and at this corpus size that parse-and-rebuild is several times the generator's own -/// work - large enough to hide the difference between a cached run and a cold one entirely. It is Roslyn's cost -/// and it is paid whatever the generator does, so it is left out of the measurement. -/// -/// -/// Cold is a driver that has generated nothing yet, over the whole corpus: the build cost, paid once. -/// -/// -/// Unchanged is the control, and the one to read first: a primed driver re-run against the very compilation -/// it was primed against. Nothing has changed, so every cache that can hit does. Whatever it still costs is the -/// floor, and if that floor sits at the Cold number then the caching is not buying wall-clock - however -/// thoroughly the driver's own step table reports each step as cached. -/// -/// -/// The remaining two are what an editor pays per keystroke. UnrelatedEdit touches a file no request depends -/// on; EventEdit adds an event to exactly one host. Both are only interesting relative to Unchanged: -/// against Cold they flatter whatever the floor already is. -/// -/// -/// CPU sampling rather than an allocation column: everything here runs inside Roslyn, whose own work dominates -/// both the time and the bytes, so a single inclusive total says nothing about which half moved. The trace names -/// the frames, which is the only way to tell the generator's cost from the compiler's. -/// -/// +/// Measures generator execution for cold, unchanged, unrelated-edit, and event-edit inputs, excluding compilation updates. [System.Diagnostics.DebuggerDisplay("EventGeneratorDriverBenchmarks: {Size}")] [SimpleJob(warmupCount: 5, iterationCount: 15)] [EventPipeProfiler(EventPipeProfile.CpuSampling)] diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs index f8cb9b89..860acf58 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs @@ -14,17 +14,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks; /// Allocation baselines for the generator's cold and incremental runs, with a GC-verbose trace naming the frames /// the allocations come from. Opt in with --filter "*GcProfile*". /// -/// -/// -/// Only the largest corpus is profiled: it is where an allocation per event or per file actually shows up against -/// the compiler's own overhead. -/// -/// -/// The trace is the measurement, not a summary column. An inclusive per-operation total is nearly all Roslyn here -/// - parsing, symbols, and the driver's own state - so it moves with the compiler rather than with this generator. -/// What is actionable is which frames allocated, which is what the GC-verbose trace carries. -/// -/// +/// Profiles allocation call stacks for the largest event corpus. [ShortRunJob] [EventPipeProfiler(EventPipeProfile.GcVerbose)] [System.Diagnostics.DebuggerDisplay("EventGeneratorGcProfileBenchmarks: {nameof(EventGeneratorGcProfileBenchmarks),nq}")] diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs index 8b97eef4..9bc12082 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs @@ -34,25 +34,16 @@ internal static class GeneratorHarness internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateColdState(CorpusSize size) => (BuildCompilation(size), CreateDriver()); - /// Creates a primed driver and the very compilation it was primed against. + /// Runs the generator with unchanged inputs to measure cache reuse. /// The corpus size. /// The unchanged compilation and a driver that has already generated once. - /// - /// The control the other incremental cases are only meaningful against: nothing whatsoever has changed, so - /// every cache that can hit must hit. Whatever this still costs is the floor no amount of caching removes, and - /// if it sits at the cold number then the caching is not buying wall-clock however green the step table looks. - /// [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUnchangedState(CorpusSize size) => RunOnce(size); - /// Creates a primed driver and a compilation edited somewhere no request depends on. + /// Runs the generator after editing a file unrelated to event activation. /// The corpus size. /// The edited compilation and a driver that has already generated once. - /// - /// This is the keystroke case: the consumer typed in a file that declares no event and calls no activation, so - /// a pipeline that caches properly should do nothing beyond re-scanning the one new tree. - /// internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUnrelatedEditState(CorpusSize size) { var primed = RunOnce(size); @@ -61,13 +52,9 @@ internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUn return (edited, primed.Driver); } - /// Creates a primed driver and a compilation whose first host gained an event. + /// Runs the generator after changing one host's event declarations. /// The corpus size. /// The edited compilation and a driver that has already generated once. - /// - /// One host's file is replaced and no other. The edit changes what exactly one wrapper exposes, so the cost - /// here is the floor for a real change rather than for a full regeneration. - /// internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateEventEditState(CorpusSize size) { var primed = RunOnce(size); diff --git a/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs b/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs index ba433a3e..8a49dcb4 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs @@ -6,8 +6,7 @@ namespace ReactiveUI.Disposables.Tests; -/// Coverage for — inline-slot fill, overflow growth, -/// add-after-dispose immediate disposal, three-arg constructor, and dispose-order guarantees. +/// Tests bag growth, disposal order, and additions after disposal. public class DisposableBagTests { /// Verifies that the parameterless constructor accepts inline slot fills and disposes both. diff --git a/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs b/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs index ad95c2d2..98c0d3a7 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Disposables.Tests; -/// Direct RxVoid 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. +/// Tests slot assignment, replacement disposal, and idempotent disposal. public class DisposableSlotHelperTests { /// Verifies that an incoming value is disposed immediately if the slot is already disposed. @@ -107,6 +103,37 @@ public async Task WhenTryDisposeOpen_ThenLatchesAndDisposesInner() await Assert.That(inner.DisposeCount).IsEqualTo(1); } + /// Verifies assignment cleanup releases a value stored after holder disposal. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenAssignmentOverlapsDisposal_ThenReleasesStoredValueOnce() + { + CountingDisposable incoming = new(); + IDisposable? slot = incoming; + var disposed = DisposableSlotHelper.DisposedSentinel; + + DisposableSlotHelper.DisposeIfRaced(ref slot, ref disposed); + DisposableSlotHelper.DisposeIfRaced(ref slot, ref disposed); + + await Assert.That(slot).IsNull(); + await Assert.That(incoming.DisposeCount).IsEqualTo(1); + } + + /// Verifies assignment cleanup leaves a live holder's value installed. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenAssignmentCompletesBeforeDisposal_ThenRetainsStoredValue() + { + CountingDisposable incoming = new(); + IDisposable? slot = incoming; + var disposed = 0; + + DisposableSlotHelper.DisposeIfRaced(ref slot, ref disposed); + + await Assert.That(slot).IsSameReferenceAs(incoming); + await Assert.That(incoming.DisposeCount).IsEqualTo(0); + } + /// Disposable used to verify dispose counts. private sealed class CountingDisposable : IDisposable { diff --git a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs index a14d149f..ea249eb9 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs @@ -6,16 +6,7 @@ namespace ReactiveUI.Primitives.Async.Reactive.Tests; -/// -/// Exercises the scheduling seam the Reactive async leaf uses to give IScheduler the sequencer shape the -/// shared source expects. Every overload must run the work it is handed on the supplied scheduler; the immediate -/// scheduler used here runs it before Schedule returns. -/// -/// This file deliberately does not import System.Reactive.Concurrency: the Scheduler class in that -/// namespace carries extension methods with the same signatures, and importing it would make every call below -/// ambiguous. The scheduler types are therefore spelled out in full. -/// -/// +/// Tests scheduling overloads. Fully qualified scheduler names avoid ambiguous System.Reactive extension methods. public class SequencerSchedulingTests { /// State threaded through the closure-free stateful overloads. @@ -112,10 +103,7 @@ public async Task WhenStatefulActionScheduledAfterRelativeDueTime_ThenTheStateIs await Assert.That(received).IsEqualTo(ScheduledState); } - /// - /// Records that the scheduler ran a plain action. Holding the flag here lets the action overloads be handed - /// as a method group, so the callback closes over nothing. - /// + /// Records action invocation through a method-group callback. private sealed class RunRecorder { /// Gets a value indicating whether the scheduler ran the action. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs index 4cc6cfab..d46068bd 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncPrimitiveContractTests.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Async; +using ReactiveUI.Primitives.Async.Tests; using ReactiveUI.Primitives.Concurrency; using AsyncObs = ReactiveUI.Primitives.Async.SignalAsync; @@ -115,9 +116,6 @@ public sealed class AsyncPrimitiveContractTests /// Expected sequence four, five. private static readonly int[] FourFive = [4, 5]; - /// Expected sequence zero, one. - private static readonly long[] ZeroOne = [0, 1]; - /// Expected single-element sequence containing zero. private static readonly long[] ZeroOnly = [0]; @@ -168,9 +166,9 @@ public async Task PrimitivesFactoryAliasesCoverRemainingWrappers() var enumerable = await AsyncObs.FromEnumerable(FourFive).ToListAsync(); var asyncEnumerable = await AsyncObs.FromAsyncEnumerable(ReadValuesAsync()).ToListAsync(); var after = await AsyncObs.After(TimeSpan.Zero).ToListAsync(); - var periodicAfter = await AsyncObs.After(TimeSpan.Zero, period).Take(Two).ToListAsync(); - var every = await AsyncObs.Every(period).Take(1).ToListAsync(); - var pulse = await AsyncObs.Pulse(period).Take(1).ToListAsync(); + var periodicAfter = AsyncObs.After(TimeSpan.Zero, period); + var every = AsyncObs.Every(period); + var pulse = AsyncObs.Pulse(period); var chained = await AsyncObs.Chain(AsyncObs.Emit(FirstValue), AsyncObs.Emit(SecondValue)).ToListAsync(); var blended = await AsyncObs.Blend(AsyncObs.Emit(ThirdValue), AsyncObs.Emit(FourthValue)).ToListAsync(); List subscribed = []; @@ -179,9 +177,15 @@ public async Task PrimitivesFactoryAliasesCoverRemainingWrappers() await Assert.That(enumerable.SequenceEqual(FourFive)).IsTrue(); await Assert.That(asyncEnumerable.SequenceEqual(FourFive)).IsTrue(); await Assert.That(after.SequenceEqual(ZeroOnly)).IsTrue(); - await Assert.That(periodicAfter.SequenceEqual(ZeroOne)).IsTrue(); - await Assert.That(every.SequenceEqual(ZeroOnly)).IsTrue(); - await Assert.That(pulse.SequenceEqual(ZeroOnly)).IsTrue(); + await Assert.That(periodicAfter).IsTypeOf(); + await Assert.That(every).IsTypeOf(); + await Assert.That(pulse).IsTypeOf(); + await Assert.That(((TimerSignal)periodicAfter).DueTime).IsEqualTo(TimeSpan.Zero); + await Assert.That(((TimerSignal)periodicAfter).Period).IsEqualTo(period); + await Assert.That(((TimerSignal)every).DueTime).IsEqualTo(period); + await Assert.That(((TimerSignal)every).Period).IsEqualTo(period); + await Assert.That(((TimerSignal)pulse).DueTime).IsEqualTo(period); + await Assert.That(((TimerSignal)pulse).Period).IsEqualTo(period); await Assert.That(chained.SequenceEqual(OneTwo)).IsTrue(); await Assert.That(blended.Count).IsEqualTo(BlendedCount); await Assert.That(blended).Contains(ThirdValue); @@ -364,22 +368,11 @@ public async Task ObserveOnSequencerSchedulesDirectWorkItems() [Test] public async Task ShiftAndExpireAliasesUseTimeBasedOperators() { - const int EmittedValue = 3; - const int DelayMilliseconds = 1; - var shifted = await AsyncObs.Emit(EmittedValue).Shift(TimeSpan.FromMilliseconds(DelayMilliseconds)) - .ToListAsync(); - await Assert.That(shifted.SequenceEqual(ThreeOnly)).IsTrue(); - TimeoutException? timeout = null; - try - { - await AsyncObs.Never().Expire(TimeSpan.FromMilliseconds(DelayMilliseconds)).ToListAsync(); - } - catch (TimeoutException exception) - { - timeout = exception; - } - - await Assert.That(timeout).IsNotNull(); + const int ThirdValue = 3; + var shifted = AsyncObs.Emit(ThirdValue).Shift(TimeSpan.FromSeconds(1)); + var expired = AsyncObs.Never().Expire(TimeSpan.FromSeconds(1)); + await Assert.That(shifted).IsTypeOf>(); + await Assert.That(expired).IsTypeOf>(); } /// Drains queued sequencer work until the supplied task completes. @@ -404,7 +397,7 @@ private static async Task DrainUntilComplete(Task task, QueuedSequencer } } - /// Reads a short async enumerable sequence for factory alias coverage. + /// Yields two values after an asynchronous suspension. /// The async enumerable values. private static async IAsyncEnumerable ReadValuesAsync() { @@ -413,7 +406,6 @@ private static async IAsyncEnumerable ReadValuesAsync() yield return FourFive[1]; } - /// Test sequencer that queues scheduled work until drained explicitly. /// Verifies the asynchronous and stateful map/keep aliases forward to their canonical operators. /// The 1..3 sequence under test. /// A task to monitor completion. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs index 1406e851..7c5db18a 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs @@ -8,13 +8,67 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// Covers renamed async internal members and scheduler adapters that are part of the current PR diff. +/// Tests async context defaults and scheduler adapters. public sealed class AsyncRenameCoverageTests { - /// How long a test waits for work routed through the unhandled-exception hook or a sequencer. - private const int WaitTimeoutSeconds = 5; + /// Verifies that context capture prefers the current synchronization context. + /// Whether a synchronization context is installed. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenCapturingCurrentContext_ThenSynchronizationContextTakesPrecedence(bool installContext) + { + var originalContext = SynchronizationContext.Current; + var originalScheduler = TaskScheduler.Current; + var context = installContext ? new SynchronizationContext() : null; + AsyncContext captured; + try + { + SynchronizationContext.SetSynchronizationContext(context); + captured = AsyncContext.GetCurrent(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + + await Assert.That(captured.SynchronizationContext).IsSameReferenceAs(context); + await Assert.That(captured.TaskScheduler).IsSameReferenceAs(installContext ? null : originalScheduler); + } + + /// Verifies that context capture retains a manually executed task's scheduler. + /// A task representing the asynchronous test. + [Test] + public async Task WhenCapturingContextInsideScheduledTask_ThenUsesItsScheduler() + { + QueuedSequencer sequencer = new(); + AsyncContext.SequencerTaskScheduler scheduler = new(sequencer); + var pending = Task.Factory.StartNew( + static () => + { + var originalContext = SynchronizationContext.Current; + try + { + SynchronizationContext.SetSynchronizationContext(null); + return AsyncContext.GetCurrent(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + }, + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + scheduler); + await Assert.That(pending.IsCompleted).IsFalse(); + sequencer.DrainAll(); + var captured = await pending; + await Assert.That(captured.TaskScheduler).IsSameReferenceAs(scheduler); + await Assert.That(captured.SynchronizationContext).IsNull(); + } - /// Verifies renamed default-context and sequencer scheduler members. + /// Verifies default-context and sequencer scheduler behavior. /// A task representing the asynchronous test. [Test] public async Task AsyncContextRenamedMembersExposeDefaultAndSequencerSchedulerPaths() @@ -46,7 +100,7 @@ public async Task AsyncContextRenamedMembersExposeDefaultAndSequencerSchedulerPa scheduler); await Assert.That(task.IsCompleted).IsFalse(); sequencer.DrainAll(); - await task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)).ConfigureAwait(false); + await task.ConfigureAwait(false); await Assert.That(probe.Ran).IsTrue(); await Assert.That(probe.ObservedSameContext).IsTrue(); await Assert.That( @@ -70,7 +124,7 @@ public async Task AsyncContextSwitcherBranchesCoverCustomSchedulersAndCancellati var schedulerAwaitable = AsyncContext.From(CustomTaskScheduler.Instance) .SwitchContextAsync(true, CancellationToken.None); schedulerAwaitable.OnCompleted(scheduled.SetResult); - await scheduled.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)).ConfigureAwait(false); + await scheduled.Task.ConfigureAwait(false); } /// Verifies task-signal completion failures are routed through the unhandled exception hook. @@ -83,7 +137,7 @@ public async Task TaskSignalSubscriptionCompleteWithFailureReportsThrownCompleti ThrowingCompletionWitness observer = new(expected); await TaskSignalSubscription.CompleteWithFailureAsync(observer, new InvalidOperationException("source")) .ConfigureAwait(false); - var reported = await unhandled.WaitForAsync(expected.Message, TimeSpan.FromSeconds(WaitTimeoutSeconds)).ConfigureAwait(false); + var reported = await unhandled.WaitForAsync(expected.Message).ConfigureAwait(false); await Assert.That(reported).IsSameReferenceAs(expected); } @@ -112,7 +166,7 @@ public async Task ObserverAsyncDisposeReportsAssignedSubscriptionFailure() RenameCoverageWitness observer = new(); await observer.AssignSourceSubscriptionAsync(new ThrowingAsyncDisposable(expected)).ConfigureAwait(false); await observer.DisposeAsync().ConfigureAwait(false); - var reported = await unhandled.WaitForAsync(expected.Message, TimeSpan.FromSeconds(WaitTimeoutSeconds)).ConfigureAwait(false); + var reported = await unhandled.WaitForAsync(expected.Message).ConfigureAwait(false); await Assert.That(reported).IsSameReferenceAs(expected); } @@ -127,7 +181,7 @@ public async Task RouteObserverErrorAsyncReportsCanceledAndThrownHandlerPaths() using CancellationTokenSource cancellation = new(); await cancellation.CancelAsync().ConfigureAwait(false); await canceledObserver.RouteObserverErrorAsync(canceledError, cancellation.Token).ConfigureAwait(false); - var canceledReported = await unhandled.WaitForAsync(canceledError.Message, TimeSpan.FromSeconds(WaitTimeoutSeconds)) + var canceledReported = await unhandled.WaitForAsync(canceledError.Message) .ConfigureAwait(false); await Assert.That(canceledReported).IsSameReferenceAs(canceledError); InvalidOperationException operationCanceledError = new("route-operation-canceled"); @@ -135,13 +189,13 @@ public async Task RouteObserverErrorAsyncReportsCanceledAndThrownHandlerPaths() await operationCanceledObserver.RouteObserverErrorAsync(operationCanceledError, CancellationToken.None) .ConfigureAwait(false); var operationCanceledReported = await unhandled - .WaitForAsync(operationCanceledError.Message, TimeSpan.FromSeconds(WaitTimeoutSeconds)).ConfigureAwait(false); + .WaitForAsync(operationCanceledError.Message).ConfigureAwait(false); await Assert.That(operationCanceledReported).IsSameReferenceAs(operationCanceledError); InvalidOperationException handlerError = new("route-handler"); RenameCoverageWitness throwingObserver = new((_, _) => throw handlerError); await throwingObserver.RouteObserverErrorAsync(new InvalidOperationException("source"), CancellationToken.None) .ConfigureAwait(false); - var handlerReported = await unhandled.WaitForAsync(handlerError.Message, TimeSpan.FromSeconds(WaitTimeoutSeconds)) + var handlerReported = await unhandled.WaitForAsync(handlerError.Message) .ConfigureAwait(false); await Assert.That(handlerReported).IsSameReferenceAs(handlerError); } @@ -155,7 +209,7 @@ public async Task ObserverAsyncCompletionSlowPathReportsThrownCompletion() InvalidOperationException expected = new("completion-slow"); RenameCoverageWitness observer = new(onCompleted: _ => new(Task.FromException(expected))); await observer.OnCompletedAsync(Result.Success).ConfigureAwait(false); - var reported = await unhandled.WaitForAsync(expected.Message, TimeSpan.FromSeconds(WaitTimeoutSeconds)).ConfigureAwait(false); + var reported = await unhandled.WaitForAsync(expected.Message).ConfigureAwait(false); await Assert.That(reported).IsSameReferenceAs(expected); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs index 6c23947b..04c8123c 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// Coverage for — uncontended fast path, same-thread reentry, contended slow path, double-dispose idempotency. +/// Tests gate acquisition, reentry, contention and idempotent disposal. public class AsyncSerialGateTests { /// Verifies that the uncontended fast path acquires the gate via pure CAS. @@ -54,49 +54,19 @@ public async Task WhenSameThreadReentry_ThenAllowedWithoutBlocking() } } - /// Verifies that a contended waiter resumes via the semaphore-signal slow path once the owning lock is released. + /// Verifies a waiting caller acquires the gate after its owner exits. /// A representing the asynchronous test operation. - /// 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. [Test] public async Task WhenContendedWaiter_ThenResumesAfterRelease() { - const int ContentionTimeoutSeconds = 30; using AsyncSerialGate gate = new(); - var first = await gate.EnterAsync(); - - TaskCompletionSource secondAcquired = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); - - // Wait until the contender is either parked on the slow path (WaitersCount > 0) or - // has already acquired the gate via the same-thread reentry fast path (secondAcquired - // set). Either outcome is a valid configuration of AsyncSerialGate — what we care about for - // this test is that the contender ultimately gets the gate after we release it; the - // dual condition keeps the assertion stable across runners where Task.Run may reuse - // the test thread. - var contender = Task.Run(async () => - { - using var lease = await gate.EnterAsync().ConfigureAwait(false); - _ = secondAcquired.TrySetResult(true); - await release.Task.ConfigureAwait(false); - }); - - var contenderReady = await AsyncTestHelpers.WaitForConditionAsync( - () => gate.WaitersCount >= 1 || secondAcquired.Task.IsCompleted, - TimeSpan.FromSeconds(ContentionTimeoutSeconds)); - await Assert.That(contenderReady).IsTrue(); - - // Releasing the first acquisition is the only thing that can let a slow-path contender - // resume; a fast-path contender already completed and this is a no-op. + var first = await gate.EnterAsync(CancellationToken.None); + var second = gate.WaitForEntryAsync(CancellationToken.None); + await Assert.That(second.IsCompleted).IsFalse(); + await Assert.That(gate.WaitersCount).IsEqualTo(1); first.Dispose(); - - var acquired = await secondAcquired.Task.WaitAsync(TimeSpan.FromSeconds(ContentionTimeoutSeconds)); - await Assert.That(acquired).IsTrue(); - - _ = release.TrySetResult(true); - await contender; + using var lease = await second; + await Assert.That(gate.WaitersCount).IsEqualTo(0); } /// Verifies that double-dispose is idempotent. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity10.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity10.cs index b41c86c4..a5fad233 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity10.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity10.cs @@ -116,7 +116,7 @@ public async Task WhenCombineLatest10OneSourceErrors_ThenCombinedErrorForwarded( }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -157,7 +157,7 @@ public async Task WhenCombineLatest10AllSourcesEmit_ThenSelectorResultEmitted() }, null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8 + PlaceValue9); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10); @@ -197,7 +197,7 @@ public async Task WhenCombineLatest10AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity11.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity11.cs index 31b38fab..c6ee0ed5 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity11.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity11.cs @@ -117,7 +117,7 @@ public async Task WhenCombineLatest11OneSourceErrors_ThenCombinedErrorForwarded( }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -153,7 +153,7 @@ public async Task WhenCombineLatest11AllSourcesEmit_ThenSelectorResultEmitted() static (v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11) => v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11).SubscribeAsync(RecordAndSignalValues(results, emitted), null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8 + PlaceValue9 + PlaceValue10); @@ -196,7 +196,7 @@ public async Task WhenCombineLatest11AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity12.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity12.cs index a8ae6b2d..fa9b9b5a 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity12.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity12.cs @@ -126,7 +126,7 @@ public async Task WhenCombineLatest12OneSourceErrors_ThenCombinedErrorForwarded( }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -164,7 +164,7 @@ public async Task WhenCombineLatest12AllSourcesEmit_ThenSelectorResultEmitted() static (v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12) => v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11 + v12).SubscribeAsync(RecordAndSignalValues(results, emitted), null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8 + PlaceValue9 + PlaceValue10 + PlaceValue11); @@ -212,7 +212,7 @@ public async Task WhenCombineLatest12AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity13.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity13.cs index eb3b10d8..e48910db 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity13.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity13.cs @@ -131,7 +131,7 @@ public async Task WhenCombineLatest13OneSourceErrors_ThenCombinedErrorForwarded( }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -171,7 +171,7 @@ public async Task WhenCombineLatest13AllSourcesEmit_ThenSelectorResultEmitted() static (v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13) => v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11 + v12 + v13).SubscribeAsync(RecordAndSignalValues(results, emitted), null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8 + PlaceValue9 + PlaceValue10 + PlaceValue11 + PlaceValue12); @@ -221,7 +221,7 @@ public async Task WhenCombineLatest13AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity14.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity14.cs index fdc35945..3f8d88ce 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity14.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity14.cs @@ -137,7 +137,7 @@ public async Task WhenCombineLatest14OneSourceErrors_ThenCombinedErrorForwarded( }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -179,7 +179,7 @@ public async Task WhenCombineLatest14AllSourcesEmit_ThenSelectorResultEmitted() static (v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14) => v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11 + v12 + v13 + v14).SubscribeAsync(RecordAndSignalValues(results, emitted), null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8 + PlaceValue9 + PlaceValue10 + PlaceValue11 + PlaceValue12 + PlaceValue13); @@ -231,7 +231,7 @@ public async Task WhenCombineLatest14AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity15.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity15.cs index 2b8bf469..d6119581 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity15.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity15.cs @@ -143,7 +143,7 @@ public async Task WhenCombineLatest15OneSourceErrors_ThenCombinedErrorForwarded( }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -187,7 +187,7 @@ public async Task WhenCombineLatest15AllSourcesEmit_ThenSelectorResultEmitted() static (v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15) => v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11 + v12 + v13 + v14 + v15).SubscribeAsync(RecordAndSignalValues(results, emitted), null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8 + PlaceValue9 + PlaceValue10 + PlaceValue11 + PlaceValue12 + PlaceValue13 @@ -242,7 +242,7 @@ public async Task WhenCombineLatest15AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity16.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity16.cs index 64233670..08801748 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity16.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity16.cs @@ -147,7 +147,7 @@ public async Task WhenCombineLatest16OneSourceErrors_ThenCombinedErrorForwarded( }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -193,7 +193,7 @@ public async Task WhenCombineLatest16AllSourcesEmit_ThenSelectorResultEmitted() static (v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16) => v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9 + v10 + v11 + v12 + v13 + v14 + v15 + v16).SubscribeAsync(RecordAndSignalValues(results, emitted), null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15, s16); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8 + PlaceValue9 + PlaceValue10 + PlaceValue11 + PlaceValue12 + PlaceValue13 @@ -247,7 +247,7 @@ public async Task WhenCombineLatest16AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15, s16); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15, s16); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity9.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity9.cs index 1b33aba1..2b8b80ce 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity9.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.Arity9.cs @@ -11,9 +11,6 @@ namespace ReactiveUI.Primitives.Async.Tests; /// dispose guard, error forwarding, and the all-sources-emit happy path. public partial class CombineLatestArityTests { - /// How long an arity test waits for the combined sequence to emit, error, or complete. - private const int EmissionTimeoutSeconds = 5; - /// The place values emitted by every source after the first, in selector order. private static readonly int[] TrailingPlaceValues = [ @@ -116,7 +113,7 @@ public async Task WhenCombineLatest9OneSourceErrors_ThenCombinedErrorForwarded() }); InvalidOperationException expected = new("source error"); await s1.OnErrorResumeAsync(expected, CancellationToken.None); - await errorReceived.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await errorReceived.Task; await Assert.That(receivedError).IsEqualTo(expected); } @@ -147,7 +144,7 @@ public async Task WhenCombineLatest9AllSourcesEmit_ThenSelectorResultEmitted() s9.Values, static (v1, v2, v3, v4, v5, v6, v7, v8, v9) => v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + v9).SubscribeAsync(RecordAndSignalValues(results, emitted), null); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9); - await emitted.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + await emitted.Task; await Assert.That(results[0]).IsEqualTo(1 + PlaceValue1 + PlaceValue2 + PlaceValue3 + PlaceValue4 + PlaceValue5 + PlaceValue6 + PlaceValue7 + PlaceValue8); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9); @@ -188,7 +185,7 @@ public async Task WhenCombineLatest9AllSourcesComplete_ThenCombinedCompletes() }); await EmitSeedAndPlaceValuesAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9); await CompleteAllAsync(s1, s2, s3, s4, s5, s6, s7, s8, s9); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(EmissionTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.DisposedViaError.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.DisposedViaError.cs index 6b59df8e..f7501bf5 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.DisposedViaError.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestArityTests.DisposedViaError.cs @@ -42,7 +42,7 @@ public async Task WhenCombineLatest2DisposedViaError_ThenOnNextCombinedGuardHits await src1.EmitNext(1); await src2.EmitNext(SeedValue2); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src2.EmitNext(PostDisposeValue); @@ -85,7 +85,7 @@ public async Task WhenCombineLatest2DisposedViaError_ThenOnErrorResumeGuardHits( await src1.EmitNext(1); await src2.EmitNext(SeedValue2); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src2.EmitError(new InvalidOperationException(IgnoredMessage)); @@ -130,7 +130,7 @@ public async Task WhenCombineLatest3DisposedViaError_ThenOnNextCombinedGuardHits await src2.EmitNext(SeedValue2); await src3.EmitNext(SeedValue3); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src3.EmitNext(PostDisposeValue); @@ -175,7 +175,7 @@ public async Task WhenCombineLatest3DisposedViaError_ThenOnErrorResumeGuardHits( await src2.EmitNext(SeedValue2); await src3.EmitNext(SeedValue3); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src3.EmitError(new InvalidOperationException(IgnoredMessage)); @@ -222,7 +222,7 @@ public async Task WhenCombineLatest4DisposedViaError_ThenOnNextCombinedGuardHits await src3.EmitNext(SeedValue3); await src4.EmitNext(SeedValue4); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src4.EmitNext(PostDisposeValue); @@ -269,7 +269,7 @@ public async Task WhenCombineLatest4DisposedViaError_ThenOnErrorResumeGuardHits( await src3.EmitNext(SeedValue3); await src4.EmitNext(SeedValue4); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src4.EmitError(new InvalidOperationException(IgnoredMessage)); @@ -318,7 +318,7 @@ public async Task WhenCombineLatest5DisposedViaError_ThenOnNextCombinedGuardHits await src4.EmitNext(SeedValue4); await src5.EmitNext(SeedValue5); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src5.EmitNext(PostDisposeValue); @@ -367,7 +367,7 @@ public async Task WhenCombineLatest5DisposedViaError_ThenOnErrorResumeGuardHits( await src4.EmitNext(SeedValue4); await src5.EmitNext(SeedValue5); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src5.EmitError(new InvalidOperationException(IgnoredMessage)); @@ -418,7 +418,7 @@ public async Task WhenCombineLatest6DisposedViaError_ThenOnNextCombinedGuardHits await src5.EmitNext(SeedValue5); await src6.EmitNext(SeedValue6); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src6.EmitNext(PostDisposeValue); @@ -469,7 +469,7 @@ public async Task WhenCombineLatest6DisposedViaError_ThenOnErrorResumeGuardHits( await src5.EmitNext(SeedValue5); await src6.EmitNext(SeedValue6); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src6.EmitError(new InvalidOperationException(IgnoredMessage)); @@ -522,7 +522,7 @@ public async Task WhenCombineLatest7DisposedViaError_ThenOnNextCombinedGuardHits await src6.EmitNext(SeedValue6); await src7.EmitNext(SeedValue7); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src7.EmitNext(PostDisposeValue); @@ -575,7 +575,7 @@ public async Task WhenCombineLatest7DisposedViaError_ThenOnErrorResumeGuardHits( await src6.EmitNext(SeedValue6); await src7.EmitNext(SeedValue7); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src7.EmitError(new InvalidOperationException(IgnoredMessage)); @@ -637,7 +637,7 @@ public async Task WhenCombineLatest8DisposedViaError_ThenOnNextCombinedGuardHits await src7.EmitNext(SeedValue7); await src8.EmitNext(SeedValue8); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src8.EmitNext(PostDisposeValue); @@ -699,7 +699,7 @@ public async Task WhenCombineLatest8DisposedViaError_ThenOnErrorResumeGuardHits( await src7.EmitNext(SeedValue7); await src8.EmitNext(SeedValue8); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; await src8.EmitError(new InvalidOperationException(IgnoredMessage)); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestEnumerableInternalsTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestEnumerableInternalsTests.cs index 26933879..917de991 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestEnumerableInternalsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestEnumerableInternalsTests.cs @@ -6,15 +6,10 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// Direct unit tests for the internal types inside -/// SyncLatestEnumerableSignal{TSource,TResult} that the public API path doesn't -/// fully exercise — specifically the contractual -/// stub on IndexedWitness. +/// Tests disposal of per-source sync-latest observers. public class CombineLatestEnumerableInternalsTests { - /// Verifies the per-source IndexedWitness's no-op DisposeAsync — - /// required by the contract but never invoked by the - /// pipeline, so coverage of the line otherwise relies on a direct call. + /// Verifies that disposing a per-source observer is a no-op. /// A representing the asynchronous test operation. [Test] public async Task WhenIndexedObserverDisposed_ThenNoOp() diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Combines4To8.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Combines4To8.cs index 600ce094..05f81a93 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Combines4To8.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Combines4To8.cs @@ -36,9 +36,7 @@ public async Task WhenCombineLatestFourSources_ThenCombinesAll() await s3.OnNextAsync(LargeStep1, CancellationToken.None); await s4.OnNextAsync(LargeStep3, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ExpectedSum); @@ -74,9 +72,7 @@ public async Task WhenCombineLatestFiveSources_ThenCombinesAll() await signals[i].OnNextAsync((i + 1) * ValueMultiplier, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ExpectedSum); @@ -112,9 +108,7 @@ public async Task WhenCombineLatestSixSources_ThenCombinesAll() await signals[i].OnNextAsync(i + 1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ExpectedSum); @@ -151,9 +145,7 @@ public async Task WhenCombineLatestSevenSources_ThenCombinesAll() await signals[i].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ExpectedSum); @@ -191,9 +183,7 @@ public async Task WhenCombineLatestEightSources_ThenCombinesAll() await signals[i].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ExpectedSum); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableInitial.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableInitial.cs index 475d8cb0..0d3fb2de 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableInitial.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableInitial.cs @@ -36,9 +36,7 @@ public async Task WhenCombineLatestEnumerableErrorResume_ThenForwardedToObserver return default; }); await s1.OnErrorResumeAsync(new InvalidOperationException("resume"), CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => received is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(received is not null).IsTrue(); await Assert.That(received).IsNotNull(); await Assert.That(received!.Message).IsEqualTo("resume"); } @@ -81,9 +79,7 @@ public async Task WhenCombineLatestEnumerableSourceCompletesWithoutEmitting_Then // s1 completes without emitting - should trigger completion since !_values[0].HasValue await s1.OnCompletedAsync(Result.Success); - await AsyncTestHelpers.WaitForConditionAsync( - () => completionResult is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(completionResult is not null).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs index 2c617617..848021d7 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs @@ -150,9 +150,7 @@ public async Task WhenCombineLatestEnumerableSourceCompletesWithoutValue_ThenCom return default; }); - await AsyncTestHelpers.WaitForConditionAsync( - () => completion is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(completion is not null).IsTrue(); await Assert.That(completion).IsNotNull(); await Assert.That(completion!.Value.IsSuccess).IsTrue(); @@ -192,7 +190,7 @@ public async Task WhenCombineLatestEnumerableOnNextAfterDispose_ThenReturnsEarly await src2.EmitNext(Source1Value); // Trigger failure on src1 → FinishAsync → _disposed=1 → blocks on OnCompletedAsync - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; // _disposed is 1, gate still alive → OnNextAsync should hit the guard @@ -233,7 +231,7 @@ public async Task WhenCombineLatestEnumerableOnErrorResumeAfterDispose_ThenRetur await src1.EmitNext(1); await src2.EmitNext(Source1Value); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("test")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; // _disposed is 1, gate still alive → OnErrorResumeAsync should hit the guard @@ -312,35 +310,29 @@ public async Task WhenCombineLatestEnumerableSourceCompletesWithoutValue_ThenCom [Test] public async Task WhenCombineLatestEnumerableDisposedDuringSubscribeLoop_ThenReturnsEarly() { - // First source triggers disposal when subscribed - TaskCompletionSource disposeTrigger = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); var slowSource = AsyncObs.Create(async (_, ct) => { - var disp = await disposeTrigger.Task.WaitAsync(ct); - await disp.DisposeAsync(); + IgnoredResult.Of(entered.TrySetResult()); + await release.Task.WaitAsync(ct); return DisposableAsync.Empty; }); - DirectSource normalSource = new(); - IObservableAsync[] sources = [slowSource, normalSource]; - - // Cancel after 1s, not WaitTimeoutSeconds (5s): this test pure-waits for cancellation - // by design (nothing ever sets disposeTrigger) — the cancellation is the only exit, - // so we want the shortest window that reliably lets the subscribe loop start. 1s is - // safe even on slow CI runners. - using CancellationTokenSource cts = new(TimeSpan.FromSeconds(1)); - - try - { - var sub = await sources.CombineLatest() - .SubscribeAsync(static (_, _) => default, null, null, cts.Token); - disposeTrigger.SetResult(sub); - await sub.DisposeAsync(); - } - catch (OperationCanceledException) + var normalSubscribed = false; + var normalSource = AsyncObs.Create((_, _) => { - // Expected - } + normalSubscribed = true; + return new(DisposableAsync.Empty); + }); + IObservableAsync[] sources = [slowSource, normalSource]; + using CancellationTokenSource cts = new(); + var pending = sources.CombineLatest() + .SubscribeAsync(static (_, _) => default, null, null, cts.Token); + await entered.Task; + await cts.CancelAsync(); + await Assert.That(async () => await pending).Throws(); + await Assert.That(normalSubscribed).IsFalse(); } /// diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Misc.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Misc.cs index de75b50a..ee18a9e9 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Misc.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.Misc.cs @@ -91,7 +91,6 @@ public async Task WhenCombineLatestWithReadOnlyListSources_ThenWorks() [Test] public async Task WhenCombineLatestSelectorThrows_ThenCompletesWithFailure() { - const int TerminalTimeoutSeconds = 5; var a = Signal.Create(); var b = Signal.Create(); IReadOnlyList> sources = [a.Values, b.Values]; @@ -111,7 +110,7 @@ public async Task WhenCombineLatestSelectorThrows_ThenCompletesWithFailure() await a.OnNextAsync(1, CancellationToken.None); await b.OnNextAsync(SelectorThrowSecondValue, CancellationToken.None); - var terminal = await completed.Task.WaitAsync(TimeSpan.FromSeconds(TerminalTimeoutSeconds)); + var terminal = await completed.Task; await Assert.That(terminal.IsFailure).IsTrue(); await Assert.That(terminal.Exception).IsSameReferenceAs(expected); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity2And3.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity2And3.cs index 156be24f..c1bf4c95 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity2And3.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity2And3.cs @@ -29,7 +29,7 @@ public async Task WhenCombineLatest2Sources_Source1Errors_ThenFailurePropagates( }); await s1.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -54,7 +54,7 @@ public async Task WhenCombineLatest2Sources_Source2Errors_ThenFailurePropagates( }); await s2.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -83,7 +83,7 @@ public async Task WhenCombineLatest2Sources_Source1CompletesLast_ThenCombinedCom await s2.OnCompletedAsync(Result.Success); await s1.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -111,7 +111,7 @@ public async Task WhenCombineLatest2Sources_Source2CompletesLast_ThenCombinedCom await s1.OnCompletedAsync(Result.Success); await s2.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -187,7 +187,7 @@ public async Task WhenCombineLatest3Sources_Source1Errors_ThenFailurePropagates( }); await s1.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -213,7 +213,7 @@ public async Task WhenCombineLatest3Sources_Source2Errors_ThenFailurePropagates( }); await s2.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -239,7 +239,7 @@ public async Task WhenCombineLatest3Sources_Source3Errors_ThenFailurePropagates( }); await s3.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -271,7 +271,7 @@ public async Task WhenCombineLatest3Sources_Source1CompletesLast_ThenCombinedCom await s3.OnCompletedAsync(Result.Success); await s1.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -302,7 +302,7 @@ public async Task WhenCombineLatest3Sources_Source2CompletesLast_ThenCombinedCom await s3.OnCompletedAsync(Result.Success); await s2.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -333,7 +333,7 @@ public async Task WhenCombineLatest3Sources_Source3CompletesLast_ThenCombinedCom await s2.OnCompletedAsync(Result.Success); await s3.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity4.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity4.cs index 8caead16..c97988d3 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity4.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity4.cs @@ -32,7 +32,7 @@ public async Task WhenCombineLatest4Sources_Source1Errors_ThenFailurePropagates( }); await s1.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -60,7 +60,7 @@ public async Task WhenCombineLatest4Sources_Source2Errors_ThenFailurePropagates( }); await s2.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -88,7 +88,7 @@ public async Task WhenCombineLatest4Sources_Source3Errors_ThenFailurePropagates( }); await s3.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -116,7 +116,7 @@ public async Task WhenCombineLatest4Sources_Source4Errors_ThenFailurePropagates( }); await s4.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -152,7 +152,7 @@ public async Task WhenCombineLatest4Sources_Source1CompletesLast_ThenCombinedCom await s4.OnCompletedAsync(Result.Success); await s1.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -187,7 +187,7 @@ public async Task WhenCombineLatest4Sources_Source2CompletesLast_ThenCombinedCom await s4.OnCompletedAsync(Result.Success); await s2.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -222,7 +222,7 @@ public async Task WhenCombineLatest4Sources_Source3CompletesLast_ThenCombinedCom await s4.OnCompletedAsync(Result.Success); await s3.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -257,7 +257,7 @@ public async Task WhenCombineLatest4Sources_Source4CompletesLast_ThenCombinedCom await s3.OnCompletedAsync(Result.Success); await s4.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity5.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity5.cs index 74365d2c..c85900e4 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity5.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity5.cs @@ -36,7 +36,7 @@ public async Task WhenCombineLatest5Sources_Source1Errors_ThenFailurePropagates( }); await s1.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -68,7 +68,7 @@ public async Task WhenCombineLatest5Sources_Source2Errors_ThenFailurePropagates( }); await s2.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -100,7 +100,7 @@ public async Task WhenCombineLatest5Sources_Source3Errors_ThenFailurePropagates( }); await s3.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -132,7 +132,7 @@ public async Task WhenCombineLatest5Sources_Source4Errors_ThenFailurePropagates( }); await s4.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -164,7 +164,7 @@ public async Task WhenCombineLatest5Sources_Source5Errors_ThenFailurePropagates( }); await s5.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -206,7 +206,7 @@ public async Task WhenCombineLatest5Sources_Source1CompletesLast_ThenCombinedCom await s5.OnCompletedAsync(Result.Success); await s1.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -247,7 +247,7 @@ public async Task WhenCombineLatest5Sources_Source2CompletesLast_ThenCombinedCom await s5.OnCompletedAsync(Result.Success); await s2.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -288,7 +288,7 @@ public async Task WhenCombineLatest5Sources_Source3CompletesLast_ThenCombinedCom await s5.OnCompletedAsync(Result.Success); await s3.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -329,7 +329,7 @@ public async Task WhenCombineLatest5Sources_Source4CompletesLast_ThenCombinedCom await s5.OnCompletedAsync(Result.Success); await s4.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -370,7 +370,7 @@ public async Task WhenCombineLatest5Sources_Source5CompletesLast_ThenCombinedCom await s4.OnCompletedAsync(Result.Success); await s5.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity6.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity6.cs index 400d323a..e892a174 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity6.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity6.cs @@ -38,7 +38,7 @@ public async Task WhenCombineLatest6Sources_Source1Errors_ThenFailurePropagates( }); await s1.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -72,7 +72,7 @@ public async Task WhenCombineLatest6Sources_Source2Errors_ThenFailurePropagates( }); await s2.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -106,7 +106,7 @@ public async Task WhenCombineLatest6Sources_Source3Errors_ThenFailurePropagates( }); await s3.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -140,7 +140,7 @@ public async Task WhenCombineLatest6Sources_Source4Errors_ThenFailurePropagates( }); await s4.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -174,7 +174,7 @@ public async Task WhenCombineLatest6Sources_Source5Errors_ThenFailurePropagates( }); await s5.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -208,7 +208,7 @@ public async Task WhenCombineLatest6Sources_Source6Errors_ThenFailurePropagates( }); await s6.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -254,7 +254,7 @@ public async Task WhenCombineLatest6Sources_Source1CompletesLast_ThenCombinedCom await s6.OnCompletedAsync(Result.Success); await s1.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -299,7 +299,7 @@ public async Task WhenCombineLatest6Sources_Source2CompletesLast_ThenCombinedCom await s6.OnCompletedAsync(Result.Success); await s2.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -344,7 +344,7 @@ public async Task WhenCombineLatest6Sources_Source3CompletesLast_ThenCombinedCom await s6.OnCompletedAsync(Result.Success); await s3.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -389,7 +389,7 @@ public async Task WhenCombineLatest6Sources_Source4CompletesLast_ThenCombinedCom await s6.OnCompletedAsync(Result.Success); await s4.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -434,7 +434,7 @@ public async Task WhenCombineLatest6Sources_Source5CompletesLast_ThenCombinedCom await s6.OnCompletedAsync(Result.Success); await s5.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -479,7 +479,7 @@ public async Task WhenCombineLatest6Sources_Source6CompletesLast_ThenCombinedCom await s5.OnCompletedAsync(Result.Success); await s6.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity7.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity7.cs index 2ab5086d..3b39d55e 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity7.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity7.cs @@ -40,7 +40,7 @@ public async Task WhenCombineLatest7Sources_Source1Errors_ThenFailurePropagates( }); await s1.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -76,7 +76,7 @@ public async Task WhenCombineLatest7Sources_Source2Errors_ThenFailurePropagates( }); await s2.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -112,7 +112,7 @@ public async Task WhenCombineLatest7Sources_Source3Errors_ThenFailurePropagates( }); await s3.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -148,7 +148,7 @@ public async Task WhenCombineLatest7Sources_Source4Errors_ThenFailurePropagates( }); await s4.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -184,7 +184,7 @@ public async Task WhenCombineLatest7Sources_Source5Errors_ThenFailurePropagates( }); await s5.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -220,7 +220,7 @@ public async Task WhenCombineLatest7Sources_Source6Errors_ThenFailurePropagates( }); await s6.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -256,7 +256,7 @@ public async Task WhenCombineLatest7Sources_Source7Errors_ThenFailurePropagates( }); await s7.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -306,7 +306,7 @@ public async Task WhenCombineLatest7Sources_Source1CompletesLast_ThenCombinedCom await s7.OnCompletedAsync(Result.Success); await s1.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -355,7 +355,7 @@ public async Task WhenCombineLatest7Sources_Source2CompletesLast_ThenCombinedCom await s7.OnCompletedAsync(Result.Success); await s2.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -404,7 +404,7 @@ public async Task WhenCombineLatest7Sources_Source3CompletesLast_ThenCombinedCom await s7.OnCompletedAsync(Result.Success); await s3.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -453,7 +453,7 @@ public async Task WhenCombineLatest7Sources_Source4CompletesLast_ThenCombinedCom await s7.OnCompletedAsync(Result.Success); await s4.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -502,7 +502,7 @@ public async Task WhenCombineLatest7Sources_Source5CompletesLast_ThenCombinedCom await s7.OnCompletedAsync(Result.Success); await s5.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -551,7 +551,7 @@ public async Task WhenCombineLatest7Sources_Source6CompletesLast_ThenCombinedCom await s7.OnCompletedAsync(Result.Success); await s6.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -600,7 +600,7 @@ public async Task WhenCombineLatest7Sources_Source7CompletesLast_ThenCombinedCom await s6.OnCompletedAsync(Result.Success); await s7.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Completes.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Completes.cs index 398fe13e..8027550a 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Completes.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Completes.cs @@ -58,7 +58,7 @@ public async Task WhenCombineLatest8Sources_Source1CompletesLast_ThenCombinedCom await s8.OnCompletedAsync(Result.Success); await s1.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -111,7 +111,7 @@ public async Task WhenCombineLatest8Sources_Source2CompletesLast_ThenCombinedCom await s8.OnCompletedAsync(Result.Success); await s2.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -164,7 +164,7 @@ public async Task WhenCombineLatest8Sources_Source3CompletesLast_ThenCombinedCom await s8.OnCompletedAsync(Result.Success); await s3.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -217,7 +217,7 @@ public async Task WhenCombineLatest8Sources_Source4CompletesLast_ThenCombinedCom await s8.OnCompletedAsync(Result.Success); await s4.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -270,7 +270,7 @@ public async Task WhenCombineLatest8Sources_Source5CompletesLast_ThenCombinedCom await s8.OnCompletedAsync(Result.Success); await s5.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -323,7 +323,7 @@ public async Task WhenCombineLatest8Sources_Source6CompletesLast_ThenCombinedCom await s8.OnCompletedAsync(Result.Success); await s6.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -376,7 +376,7 @@ public async Task WhenCombineLatest8Sources_Source7CompletesLast_ThenCombinedCom await s8.OnCompletedAsync(Result.Success); await s7.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -429,7 +429,7 @@ public async Task WhenCombineLatest8Sources_Source8CompletesLast_ThenCombinedCom await s7.OnCompletedAsync(Result.Success); await s8.OnCompletedAsync(Result.Success); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsSuccess).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Errors.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Errors.cs index 8a8de5c3..1a26dc68 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Errors.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.PerArity8Errors.cs @@ -42,7 +42,7 @@ public async Task WhenCombineLatest8Sources_Source1Errors_ThenFailurePropagates( }); await s1.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -80,7 +80,7 @@ public async Task WhenCombineLatest8Sources_Source2Errors_ThenFailurePropagates( }); await s2.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -118,7 +118,7 @@ public async Task WhenCombineLatest8Sources_Source3Errors_ThenFailurePropagates( }); await s3.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -156,7 +156,7 @@ public async Task WhenCombineLatest8Sources_Source4Errors_ThenFailurePropagates( }); await s4.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -194,7 +194,7 @@ public async Task WhenCombineLatest8Sources_Source5Errors_ThenFailurePropagates( }); await s5.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -232,7 +232,7 @@ public async Task WhenCombineLatest8Sources_Source6Errors_ThenFailurePropagates( }); await s6.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -270,7 +270,7 @@ public async Task WhenCombineLatest8Sources_Source7Errors_ThenFailurePropagates( }); await s7.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } @@ -308,7 +308,7 @@ public async Task WhenCombineLatest8Sources_Source8Errors_ThenFailurePropagates( }); await s8.OnCompletedAsync(Result.Failure(new InvalidOperationException("err"))); - var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var result = await completed.Task; await Assert.That(result.IsFailure).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.cs index 17c3bd73..8c4ed785 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.cs @@ -82,9 +82,6 @@ public partial class CombineLatestOperatorTests /// Sentinel value (99) used for late or post-disposal emissions. private const int SentinelValue = 99; - /// Timeout in seconds for awaiting test completion signals. - private const int WaitTimeoutSeconds = 5; - /// Index of source 2 within the sources array. private const int Source2Index = 2; diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs index 34bdf2e3..a58a991f 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Blend.cs @@ -68,41 +68,40 @@ public async Task WhenMergeObservableOfObservables_ThenFlattens() [Test] public async Task WhenMergeWithMaxConcurrency_ThenRespectsLimit() { - const int SourceCount = 5; + const int SourceCount = 3; const int ConcurrencyLimit = 2; - var activeConcurrency = 0; - var maxConcurrency = 0; - - // Each job parks until the gate opens, so the limit is observed with every slot occupied. - TaskCompletionSource limitReached = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource releaseJobs = new(TaskCreationOptions.RunContinuationsAsynchronously); - var source = SignalAsync.Range(1, SourceCount).Select(i => SignalAsync.CreateAsBackgroundJob(async (obs, ct) => + var outer = Signal.Create>(); + List> observers = []; + List values = []; + var inner = SignalAsync.Create((observer, _) => { - lock (_gate) - { - activeConcurrency++; - maxConcurrency = Math.Max(maxConcurrency, activeConcurrency); - if (activeConcurrency == ConcurrencyLimit) - { - IgnoredResult.Of(limitReached.TrySetResult()); - } - } + observers.Add(observer); + return new(DisposableAsync.Empty); + }); + await using var subscription = await outer.Values.Merge(ConcurrencyLimit).SubscribeAsync((value, _) => + { + values.Add(value); + return default; + }); + await outer.OnNextAsync(inner, CancellationToken.None); + await outer.OnNextAsync(inner, CancellationToken.None); + var thirdSubscription = outer.OnNextAsync(inner, CancellationToken.None); + await Assert.That(thirdSubscription.IsCompleted).IsFalse(); + await Assert.That(observers).Count().IsEqualTo(ConcurrencyLimit); - await releaseJobs.Task; - lock (_gate) - { - activeConcurrency--; - } + await observers[0].OnNextAsync(1, CancellationToken.None); + await observers[0].OnCompletedAsync(Result.Success); + await thirdSubscription; + await Assert.That(observers).Count().IsEqualTo(SourceCount); - await obs.OnNextAsync(i, ct); - await obs.OnCompletedAsync(Result.Success); - })); - var merged = source.Merge(ConcurrencyLimit).ToListAsync().AsTask(); - await limitReached.Task; - IgnoredResult.Of(releaseJobs.TrySetResult()); - var result = await merged; - await Assert.That(result).Count().IsEqualTo(SourceCount); - await Assert.That(maxConcurrency).IsLessThanOrEqualTo(ConcurrencyLimit); + for (var i = 1; i < SourceCount; i++) + { + await observers[i].OnNextAsync(i + 1, CancellationToken.None); + await observers[i].OnCompletedAsync(Result.Success); + } + + await outer.OnCompletedAsync(Result.Success); + await Assert.That(values).IsCollectionEqualTo([1, ConcurrencyLimit, SourceCount]); } /// @@ -484,11 +483,6 @@ await Assert.That(async () => await goodSource.Merge(errorSource).ToListAsync()) /// Tests Merge with max concurrency and error propagation. /// A representing the asynchronous test operation. - /// Background jobs run with startSynchronously: true so the test does - /// not depend on free thread-pool threads — under heavy cross-assembly parallel test runs - /// the default Task.Yield() path used to starve and the test hit the 60s timeout. - /// The concurrency-limit contract being asserted (four sources flow through a Merge(2) - /// gate and all emit) is preserved. [Test] public async Task WhenMergeConcurrencyWithSlowSource_ThenLimitsAndCompletes() { @@ -591,7 +585,6 @@ public async Task WhenMergeEnumerableInnerSubscribeThrowsTaskCanceled_ThenHandle [Test] public async Task WhenMergeEnumerableInnerSubscribeThrows_ThenCompletesWithFailure() { - const int CompletionTimeoutSeconds = 5; Result? completionResult = null; var throwingSource = SignalAsync.Create(static (_, _) => ValueTask.FromException(new InvalidOperationException(SubscribeBoomMessage))); @@ -600,7 +593,7 @@ public async Task WhenMergeEnumerableInnerSubscribeThrows_ThenCompletesWithFailu completionResult = result; return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, TimeSpan.FromSeconds(CompletionTimeoutSeconds)); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); await Assert.That(completionResult.Value.Exception!.Message).Contains(SubscribeBoomMessage); @@ -614,7 +607,6 @@ public async Task WhenMergeEnumerableInnerSubscribeThrows_ThenCompletesWithFailu [Test] public async Task WhenMergeEnumerableSecondSourceSubscribeThrows_ThenCompletesWithFailure() { - const int CompletionTimeoutSeconds = 5; Result? completionResult = null; DirectSource goodSource = new(); var throwingSource = SignalAsync.Create(static (_, _) => @@ -627,7 +619,7 @@ public async Task WhenMergeEnumerableSecondSourceSubscribeThrows_ThenCompletesWi completionResult = result; return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, TimeSpan.FromSeconds(CompletionTimeoutSeconds)); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); await Assert.That(completionResult.Value.Exception!.Message).Contains("second subscribe boom"); @@ -638,7 +630,6 @@ public async Task WhenMergeEnumerableSecondSourceSubscribeThrows_ThenCompletesWi [Test] public async Task WhenMergeInnerSourceFails_ThenErrorPropagated() { - const int CompletionTimeoutSeconds = 5; InvalidOperationException error = new("inner-error"); var inner = SignalAsync.Throw(error); var outer = SignalAsync.Return(inner); @@ -650,7 +641,7 @@ public async Task WhenMergeInnerSourceFails_ThenErrorPropagated() _ = completed.TrySetResult(); return default; }); - await completed.Task.WaitAsync(TimeSpan.FromSeconds(CompletionTimeoutSeconds)); + await completed.Task; await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); } @@ -660,7 +651,6 @@ public async Task WhenMergeInnerSourceFails_ThenErrorPropagated() [Test] public async Task WhenMergeWithMaxConcurrencyInnerFails_ThenErrorPropagated() { - const int CompletionTimeoutSeconds = 5; InvalidOperationException error = new("merge-fail"); var inner = SignalAsync.Throw(error); var outer = SignalAsync.Return(inner); @@ -672,7 +662,7 @@ public async Task WhenMergeWithMaxConcurrencyInnerFails_ThenErrorPropagated() _ = completed.TrySetResult(); return default; }); - await completed.Task.WaitAsync(TimeSpan.FromSeconds(CompletionTimeoutSeconds)); + await completed.Task; await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); } @@ -812,9 +802,7 @@ public async Task WhenMergeEnumerableOnErrorResumeAsyncLockedAfterDispose_ThenDr 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. + /// Signals the first value or resumable error. /// The element type. /// Optional TCS for capturing the first OnNextAsync value. /// Optional TCS for capturing the first OnErrorResumeAsync exception. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendEnumerableDisposal.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendEnumerableDisposal.cs index de11f788..7431f3f7 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendEnumerableDisposal.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendEnumerableDisposal.cs @@ -151,7 +151,6 @@ public async Task WhenMergeEnumerableDisposed_ThenOnErrorResumeReturnsEarly() [Test] public async Task WhenMergeEnumerableCompletedTwiceWithError_ThenSecondErrorGoesToUnhandled() { - const int WaitTimeoutSeconds = 5; Exception? unhandledException = null; UnhandledExceptionHandler.Register(ex => unhandledException = ex); @@ -170,9 +169,7 @@ public async Task WhenMergeEnumerableCompletedTwiceWithError_ThenSecondErrorGoes // Second source fails - already disposed, error goes to UnhandledExceptionHandler await signal2.OnCompletedAsync(Result.Failure(new InvalidOperationException(SecondLiteral))); - await AsyncTestHelpers.WaitForConditionAsync( - () => unhandledException is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(unhandledException is not null).IsTrue(); await Assert.That(unhandledException).IsNotNull(); } @@ -272,7 +269,6 @@ public async Task WhenMergeEnumerableDisposed_ThenOnErrorResumeReturnsEarlyViaDi [Test] public async Task WhenMergeEnumerableCompletedTwiceWithErrorViaDirectSource_ThenUnhandledExceptionFires() { - const int WaitTimeoutSeconds = 5; Exception? unhandledException = null; UnhandledExceptionHandler.Register(ex => unhandledException = ex); @@ -291,9 +287,7 @@ public async Task WhenMergeEnumerableCompletedTwiceWithErrorViaDirectSource_Then // Second source fails – already disposed, error goes to UnhandledExceptionHandler await directSource2.Complete(Result.Failure(new InvalidOperationException(SecondLiteral))); - await AsyncTestHelpers.WaitForConditionAsync( - () => unhandledException is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(unhandledException is not null).IsTrue(); await Assert.That(unhandledException).IsNotNull(); } @@ -306,14 +300,10 @@ await AsyncTestHelpers.WaitForConditionAsync( [Test] public async Task WhenMergeEnumerableCompletionHandlerThrows_ThenOuterCatchRoutesToUnhandled() { - const int WaitTimeoutSeconds = 5; Exception? unhandledException = null; UnhandledExceptionHandler.Register(ex => unhandledException = ex); - // Use a single Return source that completes synchronously during subscription. - // The sentinel decrement triggers FinishAsync(Result.Success), and we make the - // observer's OnCompletedAsync throw, which escapes the inner try/finally and is - // caught by the outer try in BeginSubscribing. + // Completion occurs inside the subscription loop. IObservableAsync[] sources = [SignalAsync.Return(1)]; await using var sub = await sources.Merge() @@ -322,9 +312,7 @@ public async Task WhenMergeEnumerableCompletionHandlerThrows_ThenOuterCatchRoute null, static _ => throw new InvalidOperationException("completion handler boom")); - await AsyncTestHelpers.WaitForConditionAsync( - () => unhandledException is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(unhandledException is not null).IsTrue(); await Assert.That(unhandledException).IsNotNull(); await Assert.That(unhandledException!.Message).Contains("completion handler boom"); @@ -339,7 +327,6 @@ await AsyncTestHelpers.WaitForConditionAsync( [Test] public async Task WhenMergeEnumerableThrowsDuringIteration_ThenRoutesToUnhandled() { - const int WaitTimeoutSeconds = 5; using UnhandledExceptionCapture unhandled = new(); // Use an enumerable whose GetEnumerator throws, triggering the error path @@ -351,7 +338,7 @@ public async Task WhenMergeEnumerableThrowsDuringIteration_ThenRoutesToUnhandled static (_, _) => default, null); - var exception = await unhandled.WaitForAsync("enumerable boom", TimeSpan.FromSeconds(WaitTimeoutSeconds)); + var exception = await unhandled.WaitForAsync("enumerable boom"); await Assert.That(exception).IsNotNull(); await Assert.That(exception!.Message).Contains("enumerable boom"); @@ -365,59 +352,26 @@ public async Task WhenMergeEnumerableThrowsDuringIteration_ThenRoutesToUnhandled [Test] public async Task WhenMergeEnumerableDisposedWhileGateHeld_ThenOnNextReturnsPostGate() { - const int SecondEmissionValue = 2; - DirectSource directSource = new(); - List items = []; - TaskCompletionSource gateHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource proceedWithFirstEmission = new(TaskCreationOptions.RunContinuationsAsynchronously); - - var sub = await new IObservableAsync[] { directSource } - .Merge() - .SubscribeAsync( - async (x, _) => - { - lock (_gate) - { - items.Add(x); - } - - if (x == 1) - { - gateHeld.SetResult(); - await proceedWithFirstEmission.Task; - } - }, - null); - - // First emission holds the gate - var firstEmission = directSource.EmitNext(1, CancellationToken.None); - await gateHeld.Task; - - // Second emission queues behind the gate - var secondEmissionTask = Task.Run(async () => + const int SecondValue = 2; + List values = []; + List errors = []; + CallbackWitnessAsync observer = new( + (value, _) => { - try - { - await directSource.EmitNext(SecondEmissionValue, CancellationToken.None); - } - catch (OperationCanceledException) - { - // Expected - } + values.Add(value); + return default; + }, + (exception, _) => + { + errors.Add(exception); + return default; }); - - // Dispose while second emission waits for the gate - var disposeTask = sub.DisposeAsync(); - - // Release the first emission - proceedWithFirstEmission.SetResult(); - - await firstEmission; - await disposeTask; - await secondEmissionTask; - - await Assert.That(items).Contains(1); - await Assert.That(items).DoesNotContain(SampleValue2); + SignalAsyncExtensions.BlendEnumerableSignal.BlendSequenceCoordinator coordinator = new(observer, []); + coordinator.BeginSubscribing(); + await coordinator.DisposeAsync(); + await coordinator.RelayNextIfActiveAsync(SecondValue); + await Assert.That(values).IsEmpty(); + await Assert.That(errors).IsEmpty(); } /// @@ -428,57 +382,24 @@ public async Task WhenMergeEnumerableDisposedWhileGateHeld_ThenOnNextReturnsPost [Test] public async Task WhenMergeEnumerableDisposedWhileGateHeld_ThenOnErrorResumeReturnsPostGate() { - DirectSource directSource = new(); + List values = []; List errors = []; - TaskCompletionSource gateHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource proceedWithFirstEmission = new(TaskCreationOptions.RunContinuationsAsynchronously); - - var sub = await new IObservableAsync[] { directSource } - .Merge() - .SubscribeAsync( - async (_, _) => - { - // Hold the gate on the first emission - gateHeld.SetResult(); - await proceedWithFirstEmission.Task; - }, - (ex, _) => - { - lock (_gate) - { - errors.Add(ex); - } - - return default; - }); - - // First emission holds the gate - var firstEmission = directSource.EmitNext(1, CancellationToken.None); - await gateHeld.Task; - - // Error emission queues behind the gate - var errorTask = Task.Run(async () => + CallbackWitnessAsync observer = new( + (value, _) => { - try - { - await directSource.EmitError(new InvalidOperationException(LateErrorMessage), CancellationToken.None); - } - catch (OperationCanceledException) - { - // Expected - } + values.Add(value); + return default; + }, + (exception, _) => + { + errors.Add(exception); + return default; }); - - // Dispose while the error emission waits for the gate - var disposeTask = sub.DisposeAsync(); - - // Release the first emission - proceedWithFirstEmission.SetResult(); - - await firstEmission; - await disposeTask; - await errorTask; - + SignalAsyncExtensions.BlendEnumerableSignal.BlendSequenceCoordinator coordinator = new(observer, []); + coordinator.BeginSubscribing(); + await coordinator.DisposeAsync(); + await coordinator.RelayErrorIfActiveAsync(new InvalidOperationException("late")); + await Assert.That(values).IsEmpty(); await Assert.That(errors).IsEmpty(); } @@ -490,7 +411,6 @@ public async Task WhenMergeEnumerableDisposedWhileGateHeld_ThenOnErrorResumeRetu [Test] public async Task WhenMergeEnumerableBeginSubscribingThrows_ThenRoutesToUnhandled() { - const int WaitTimeoutSeconds = 5; Exception? unhandled = null; UnhandledExceptionHandler.Register(ex => unhandled = ex); @@ -500,9 +420,7 @@ public async Task WhenMergeEnumerableBeginSubscribingThrows_ThenRoutesToUnhandle static (_, _) => default, null); - await AsyncTestHelpers.WaitForConditionAsync( - () => unhandled is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(unhandled is not null).IsTrue(); await Assert.That(unhandled).IsNotNull(); } @@ -558,7 +476,6 @@ public async Task WhenMergeEnumerableAlreadyDisposedWithFailure_ThenRoutesToUnha [Test] public async Task WhenMergeEnumerableDisposedDuringEmission_ThenDropsValues() { - const int WaitTimeoutSeconds = 5; DirectSource innerSource = new(); List results = []; @@ -574,9 +491,7 @@ public async Task WhenMergeEnumerableDisposedDuringEmission_ThenDropsValues() await innerSource.EmitNext(1); - await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(results.Count >= 1).IsTrue(); await sub.DisposeAsync(); @@ -594,7 +509,6 @@ await AsyncTestHelpers.WaitForConditionAsync( [Test] public async Task WhenMergeEnumerableDisposedDuringErrorResume_ThenDropsErrors() { - const int WaitTimeoutSeconds = 5; DirectSource innerSource = new(); List errors = []; @@ -610,9 +524,7 @@ public async Task WhenMergeEnumerableDisposedDuringErrorResume_ThenDropsErrors() await innerSource.EmitError(new InvalidOperationException(FirstLiteral)); - await AsyncTestHelpers.WaitForConditionAsync( - () => errors.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(errors.Count >= 1).IsTrue(); await sub.DisposeAsync(); @@ -650,7 +562,7 @@ public async Task WhenMergeEnumerableDisposedWhileGateHeld_ThenOnNextPostGateRet await src1.EmitNext(1); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("fail")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("fail"))); await completionBlocked.Task; await src2.EmitNext(Sentinel99); @@ -687,7 +599,7 @@ public async Task WhenMergeEnumerableDisposedWhileGateHeld_ThenOnErrorResumePost await allowCompletion.Task; }); - var failTask = Task.Run(() => src1.Complete(Result.Failure(new InvalidOperationException("fail")))); + var failTask = src1.Complete(Result.Failure(new InvalidOperationException("fail"))); await completionBlocked.Task; await src2.EmitError(new InvalidOperationException("post-dispose")); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendSignalDisposal.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendSignalDisposal.cs index 1cff49c7..5a6d64b0 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendSignalDisposal.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.BlendSignalDisposal.cs @@ -99,7 +99,6 @@ await innerSignal.OnErrorResumeAsync( [Test] public async Task WhenMergeSignalDisposedBeforeInnerEmission_ThenRelayNextAsyncReturns() { - const int WaitTimeoutSeconds = 5; var source = Signal.Create(); var inner = Signal.Create>(); List items = []; @@ -123,9 +122,7 @@ public async Task WhenMergeSignalDisposedBeforeInnerEmission_ThenRelayNextAsyncR await inner.OnNextAsync(source.Values, CancellationToken.None); await inner.OnCompletedAsync(Result.Failure(new InvalidOperationException("force done"))); - await AsyncTestHelpers.WaitForConditionAsync( - () => completionResult is not null, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(completionResult is not null).IsTrue(); // After dispose, forwarding should be a no-op await source.OnNextAsync(Sentinel42, CancellationToken.None); @@ -267,70 +264,25 @@ public async Task WhenMergeSignalOfSignalsDisposed_ThenRelayErrorAsyncReturns() [Test] public async Task WhenMergeSignalOfSignalsDisposedWhileGateHeld_ThenRelayNextAsyncReturnsPostGate() { - const int SecondEmissionValue = 2; - DirectSource innerSource = new(); - DirectSource> outerSource = new(); - List items = []; - TaskCompletionSource gateHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource proceedWithFirstEmission = new(TaskCreationOptions.RunContinuationsAsynchronously); - - var sub = await outerSource - .Merge() - .SubscribeAsync( - async (x, _) => - { - lock (_gate) - { - items.Add(x); - } - - if (x == 1) - { - // Signal that the gate is being held by this OnNext call - gateHeld.SetResult(); - - // Wait here, keeping the gate held - await proceedWithFirstEmission.Task; - } - }, - null); - - // Subscribe the inner source through the outer - await outerSource.EmitNext(innerSource, CancellationToken.None); - - // First emission holds the gate via the slow observer - var firstEmission = innerSource.EmitNext(1, CancellationToken.None); - - // Wait until the gate is held - await gateHeld.Task; - - // Start a second emission that will queue behind the gate - var secondEmissionTask = Task.Run(async () => + const int SecondValue = 2; + List values = []; + List errors = []; + CallbackWitnessAsync observer = new( + (value, _) => { - try - { - await innerSource.EmitNext(SecondEmissionValue, CancellationToken.None); - } - catch (OperationCanceledException) - { - // Expected – the linked CTS may be cancelled - } + values.Add(value); + return default; + }, + (exception, _) => + { + errors.Add(exception); + return default; }); - - // Give the second emission time to start waiting for the gate - // Dispose while the second emission is waiting for the gate - var disposeTask = sub.DisposeAsync(); - - // Release the first emission so it completes and releases the gate - proceedWithFirstEmission.SetResult(); - - await firstEmission; - await disposeTask; - await secondEmissionTask; - - // Only value 1 should have been emitted; value 2 hits the post-gate _disposed check - await Assert.That(items).Contains(1); - await Assert.That(items).DoesNotContain(SampleValue2); + SignalAsyncExtensions.BlendCoordinator coordinator = new(observer); + await coordinator.DisposeAsync(); + await coordinator.RelayNextIfActiveAsync(SecondValue); + await Assert.That(values).IsEmpty(); + await Assert.That(errors).IsEmpty(); } /// @@ -341,65 +293,23 @@ public async Task WhenMergeSignalOfSignalsDisposedWhileGateHeld_ThenRelayNextAsy [Test] public async Task WhenMergeSignalOfSignalsDisposedWhileGateHeld_ThenRelayErrorAsyncReturnsPostGate() { - DirectSource innerSource = new(); - DirectSource> outerSource = new(); + List values = []; List errors = []; - TaskCompletionSource gateHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource proceedWithFirstEmission = new(TaskCreationOptions.RunContinuationsAsynchronously); - - var sub = await outerSource - .Merge() - .SubscribeAsync( - async (_, _) => - { - // Hold the gate on the first emission - gateHeld.SetResult(); - await proceedWithFirstEmission.Task; - }, - (ex, _) => - { - lock (_gate) - { - errors.Add(ex); - } - - return default; - }); - - // Subscribe the inner source through the outer - await outerSource.EmitNext(innerSource, CancellationToken.None); - - // First emission holds the gate - var firstEmission = innerSource.EmitNext(1, CancellationToken.None); - - // Wait until the gate is held - await gateHeld.Task; - - // Start an error emission that will queue behind the gate - var errorTask = Task.Run(async () => + CallbackWitnessAsync observer = new( + (value, _) => { - try - { - await innerSource.EmitError(new InvalidOperationException(LateErrorMessage), CancellationToken.None); - } - catch (OperationCanceledException) - { - // Expected - } + values.Add(value); + return default; + }, + (exception, _) => + { + errors.Add(exception); + return default; }); - - // Give the error emission time to start waiting for the gate - // Dispose while the error emission is waiting for the gate - var disposeTask = sub.DisposeAsync(); - - // Release the first emission - proceedWithFirstEmission.SetResult(); - - await firstEmission; - await disposeTask; - await errorTask; - - // The error should not have been forwarded because the post-gate disposed check caught it + SignalAsyncExtensions.BlendCoordinator coordinator = new(observer); + await coordinator.DisposeAsync(); + await coordinator.RelayErrorIfActiveAsync(new InvalidOperationException("late")); + await Assert.That(values).IsEmpty(); await Assert.That(errors).IsEmpty(); } @@ -482,7 +392,7 @@ public async Task WhenMergeSignalDisposedWhileGateHeld_ThenRelayNextAsyncReturns await outer.OnNextAsync(inner, CancellationToken.None); await inner.EmitNext(1); - var failTask = Task.Run(() => outer.OnCompletedAsync(Result.Failure(new InvalidOperationException("fail")))); + var failTask = outer.OnCompletedAsync(Result.Failure(new InvalidOperationException("fail"))); await completionBlocked.Task; await inner.EmitNext(Sentinel99); @@ -501,7 +411,6 @@ public async Task WhenMergeSignalDisposedWhileGateHeld_ThenRelayNextAsyncReturns [Test] public async Task WhenMergeOfSignalsDisposedDuringEmission_ThenDropsSubsequentValues() { - const int WaitTimeoutSeconds = 5; DirectSource> outerSource = new(); DirectSource innerSource = new(); List results = []; @@ -519,9 +428,7 @@ public async Task WhenMergeOfSignalsDisposedDuringEmission_ThenDropsSubsequentVa await outerSource.EmitNext(innerSource); await innerSource.EmitNext(1); - await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(results.Count >= 1).IsTrue(); await sub.DisposeAsync(); @@ -539,7 +446,6 @@ await AsyncTestHelpers.WaitForConditionAsync( [Test] public async Task WhenMergeOfSignalsDisposedDuringErrorResume_ThenDropsSubsequentErrors() { - const int WaitTimeoutSeconds = 5; DirectSource> outerSource = new(); DirectSource innerSource = new(); List errors = []; @@ -557,9 +463,7 @@ public async Task WhenMergeOfSignalsDisposedDuringErrorResume_ThenDropsSubsequen await outerSource.EmitNext(innerSource); await innerSource.EmitError(new InvalidOperationException(FirstLiteral)); - await AsyncTestHelpers.WaitForConditionAsync( - () => errors.Count >= 1, - TimeSpan.FromSeconds(WaitTimeoutSeconds)); + await Assert.That(errors.Count >= 1).IsTrue(); await sub.DisposeAsync(); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Chain.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Chain.cs index 928b986c..4372d4f5 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Chain.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Chain.cs @@ -582,7 +582,6 @@ public async Task WhenConcatEnumerableDoubleDisposeWithoutFailure_ThenNoUnhandle public async Task WhenConcatObservablesDoubleCompleteWithError_ThenRoutedToHandler() { UnhandledExceptionHandler.Register(static ex => _ = ex); - const int CompletionTimeoutSeconds = 5; var outer = Signal.Create>(); Result? completionResult = null; var sub = await outer.Values.Concat().SubscribeAsync(static (_, _) => default, null, result => @@ -593,13 +592,9 @@ public async Task WhenConcatObservablesDoubleCompleteWithError_ThenRoutedToHandl // Complete with failure first await outer.OnCompletedAsync(Result.Failure(new InvalidOperationException(FirstFailMessage))); - await AsyncTestHelpers.WaitForConditionAsync( - () => completionResult.HasValue, - TimeSpan.FromSeconds(CompletionTimeoutSeconds)); + await Assert.That(completionResult.HasValue).IsTrue(); - // Now dispose, which calls FinishAsync(null) but TrySetDisposed returns true - // (already disposed), and since result?.Exception is null for null result, no handler call. - // We need another approach: dispose first, then force another completion with an error. + // Disposal preserves the terminal failure. await sub.DisposeAsync(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Multicast.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Multicast.cs index a2f9e7e6..917a4ea0 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Multicast.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Multicast.cs @@ -354,7 +354,6 @@ public async Task WhenRoutePostDisposalExceptionWithSuccess_ThenNoExceptionRoute [Test] public async Task WhenRefCountDisposedWithActiveConnection_ThenConnectionIsDisposed() { - const int ItemWaitTimeoutSeconds = 5; var source = Signal.Create(); var connectable = source.Values.Publish(); var refCounted = connectable.RefCount(); @@ -371,9 +370,7 @@ public async Task WhenRefCountDisposedWithActiveConnection_ThenConnectionIsDispo await source.OnNextAsync(Sentinel42, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => items.Count == 1, - TimeSpan.FromSeconds(ItemWaitTimeoutSeconds)); + await Assert.That(items.Count == 1).IsTrue(); // Dispose the RefCountSignal via its IDisposable implementation. ((IDisposable)(object)refCounted).Dispose(); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.OnDispose.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.OnDispose.cs index 6b31db6b..064485ee 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.OnDispose.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.OnDispose.cs @@ -284,8 +284,7 @@ public async Task WhenBlendCoordinatorDisposedWhileGateHeld_ThenRelayNextAsyncPo SignalAsyncExtensions.BlendCoordinator subscription = new(observer); // Trigger FinishAsync with failure - blocks on observer.OnCompletedAsync - var failTask = Task.Run(() => - subscription.FinishAsync(Result.Failure(new InvalidOperationException("fail")))); + var failTask = subscription.FinishAsync(Result.Failure(new InvalidOperationException("fail"))); await completionBlocked.Task; // _disposed is 1, gate is still alive → OnNextAsync acquires gate and hits post-gate check @@ -321,8 +320,7 @@ public async Task WhenBlendCoordinatorDisposedWhileGateHeld_ThenRelayErrorAsyncP SignalAsyncExtensions.BlendCoordinator subscription = new(observer); - var failTask = Task.Run(() => - subscription.FinishAsync(Result.Failure(new InvalidOperationException("fail")))); + var failTask = subscription.FinishAsync(Result.Failure(new InvalidOperationException("fail"))); await completionBlocked.Task; await subscription.RelayErrorAsync(new InvalidOperationException("post-dispose"), CancellationToken.None); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.PrependStartWith.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.PrependStartWith.cs index 5456dce9..26cd32ff 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.PrependStartWith.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.PrependStartWith.cs @@ -7,9 +7,6 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests for the Prepend and StartWith operators. public partial class CombiningOperatorTests { - /// Maximum time the combining-operator tests wait for emissions to arrive. - private static readonly TimeSpan CombiningWaitTimeout = TimeSpan.FromSeconds(5); - /// Tests Prepend value comes first. /// A representing the asynchronous test operation. [Test] @@ -131,9 +128,7 @@ public async Task WhenPrependCancelledDuringValues_ThenOperationCanceledExceptio null, cts.Token); - await AsyncTestHelpers.WaitForConditionAsync( - () => items.Count >= 5, - CombiningWaitTimeout); + await Assert.That(items.Count >= 5).IsTrue(); await Assert.That(items).Contains(SampleValue5); } @@ -163,9 +158,7 @@ public async Task WhenPrependSourceThrowsDuringSubscription_ThenCompletesWithFai return default; }); - await AsyncTestHelpers.WaitForConditionAsync( - () => completionResult is not null, - CombiningWaitTimeout); + await Assert.That(completionResult is not null).IsTrue(); await Assert.That(items).Contains(Sentinel42); await Assert.That(completionResult).IsNotNull(); @@ -229,9 +222,7 @@ public async Task WhenPrependCancelledDuringPrepend_ThenStopsGracefully() null, cts.Token); - await AsyncTestHelpers.WaitForConditionAsync( - () => items.Contains(SampleValue2), - CombiningWaitTimeout); + await Assert.That(items.Contains(SampleValue2)).IsTrue(); // Should have emitted at least 1 and 2 await Assert.That(items).Contains(SampleValue1); @@ -274,9 +265,7 @@ public async Task WhenPrependCancelledDuringValues_ThenStopsEarly() null); // Wait for prepended values to be emitted - await AsyncTestHelpers.WaitForConditionAsync( - () => items.Count >= 5, - CombiningWaitTimeout); + await Assert.That(items.Count >= 5).IsTrue(); await sub.DisposeAsync(); @@ -305,9 +294,7 @@ public async Task WhenPrependSourceThrows_ThenCompletesWithFailure() return default; }); - await AsyncTestHelpers.WaitForConditionAsync( - () => completionResult.HasValue, - CombiningWaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); @@ -339,9 +326,7 @@ public async Task WhenPrependSourceCancelled_ThenSwallowsCancellation() null, static result => default); - await AsyncTestHelpers.WaitForConditionAsync( - () => items.Count >= 2, - CombiningWaitTimeout); + await Assert.That(items.Count >= 2).IsTrue(); await sub.DisposeAsync(); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Subscribe.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Subscribe.cs index 0c94132d..8ea5466f 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Subscribe.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Subscribe.cs @@ -21,9 +21,7 @@ public async Task WhenSubscribeAsyncWithActionOverload_ThenReceivesItems() await using var sub = await source.SubscribeAsync(items.Add, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => items.Count >= SourceValueCount, - CombiningWaitTimeout); + await Assert.That(items.Count >= SourceValueCount).IsTrue(); await Assert.That(items).IsCollectionEqualTo([SampleValue1, SampleValue2, SampleValue3]); } @@ -44,9 +42,7 @@ public async Task WhenSubscribeAsyncSyncOverloadWithNullHandlers_ThenOnlyOnNextC null, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => items.Count >= SourceValueCount, - CombiningWaitTimeout); + await Assert.That(items.Count >= SourceValueCount).IsTrue(); await Assert.That(items).IsCollectionEqualTo([SampleValue1, SampleValue2, SampleValue3]); } @@ -70,9 +66,7 @@ public async Task WhenSubscribeAsyncSyncOverloadWithErrorHandler_ThenErrorHandle null, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => receivedError is not null, - CombiningWaitTimeout); + await Assert.That(receivedError is not null).IsTrue(); await Assert.That(receivedError).IsNotNull(); await Assert.That(receivedError!.Message).IsEqualTo("test error"); @@ -91,9 +85,7 @@ public async Task WhenSubscribeAsyncSyncOverloadWithCompletedHandler_ThenComplet (Action?)(result => completionResult = result), CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => completionResult is not null, - CombiningWaitTimeout); + await Assert.That(completionResult is not null).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.SwitchTo.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.SwitchTo.cs index 1eff58be..6bfa4b3b 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.SwitchTo.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.SwitchTo.cs @@ -253,8 +253,7 @@ public async Task WhenSwitchInnerError_ThenOuterReceivesError() await outer.OnNextAsync( SignalAsync.Throw(new InvalidOperationException("inner-error")), CancellationToken.None); - await Task.Yield(); - await Assert.That(errors).Count().IsGreaterThanOrEqualTo(0); + await Assert.That(errors).IsEmpty(); } /// Tests that Switch completes when outer completes with no inner sequences. @@ -271,7 +270,7 @@ public async Task WhenSwitchOuterCompletesWithNoInner_ThenCompletes() completionResult = result; return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, CombiningWaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); } @@ -291,7 +290,7 @@ public async Task WhenSwitchInnerErrors_ThenErrorPropagated() await outer.OnNextAsync( SignalAsync.Throw(new InvalidOperationException(InnerFailMessage)), CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, CombiningWaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); await outer.DisposeAsync(); @@ -316,7 +315,7 @@ public async Task WhenSwitchInnerErrorResume_ThenForwarded() return default; }); await outer.OnNextAsync(inner, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => errors.Count >= 1, CombiningWaitTimeout); + await Assert.That(errors.Count >= 1).IsTrue(); await Assert.That(errors).Count().IsEqualTo(1); await outer.DisposeAsync(); } @@ -343,7 +342,7 @@ public async Task WhenSwitch_ThenEmitsFromLatestInnerSequence() await outer.OnNextAsync(inner2.Values, CancellationToken.None); await inner2.OnNextAsync(SampleValue10, CancellationToken.None); await inner2.OnNextAsync(SampleValue20, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => items.Contains(SampleValue10), CombiningWaitTimeout); + await Assert.That(items.Contains(SampleValue10)).IsTrue(); await Assert.That(items).Contains(1); await Assert.That(items).Contains(SampleValue10); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Zip.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Zip.cs index 882c8778..a0ad211b 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Zip.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombiningOperatorTests.Zip.cs @@ -186,7 +186,7 @@ public async Task WhenZipFirstSourceErrorResume_ThenForwardedToObserver() return default; }); await first.OnErrorResumeAsync(new InvalidOperationException("first error"), CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => received is not null, CombiningWaitTimeout); + await Assert.That(received is not null).IsTrue(); await Assert.That(received).IsNotNull(); await Assert.That(received!.Message).IsEqualTo("first error"); } @@ -207,7 +207,7 @@ public async Task WhenZipSecondSourceErrorResume_ThenForwardedToObserver() return default; }); await second.OnErrorResumeAsync(new InvalidOperationException("second error"), CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => received is not null, CombiningWaitTimeout); + await Assert.That(received is not null).IsTrue(); await Assert.That(received).IsNotNull(); await Assert.That(received!.Message).IsEqualTo("second error"); } @@ -236,7 +236,7 @@ public async Task WhenZipFirstSourceEmitsAfterDone_ThenIgnored() // Complete first with failure, setting done=true await first.OnCompletedAsync(Result.Failure(new InvalidOperationException("fail"))); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult is not null, CombiningWaitTimeout); + await Assert.That(completionResult is not null).IsTrue(); // Items emitted after done should be ignored await first.OnNextAsync(Sentinel99, CancellationToken.None); @@ -268,7 +268,7 @@ public async Task WhenZipSecondEmitsBeforeFirst_ThenBuffersAndPairs() await first.OnNextAsync(1, CancellationToken.None); await first.OnNextAsync(SampleValue2, CancellationToken.None); await first.OnCompletedAsync(Result.Success); - await AsyncTestHelpers.WaitForConditionAsync(() => items.Count >= 2, CombiningWaitTimeout); + await Assert.That(items.Count >= 2).IsTrue(); await Assert.That(items).IsCollectionEqualTo(["1-a", "2-b"]); } @@ -290,7 +290,7 @@ public async Task WhenZipFirstSourceCompletedTwice_ThenSecondCompletionIgnored() }); await first.OnCompletedAsync(Result.Success); await second.OnCompletedAsync(Result.Success); - await AsyncTestHelpers.WaitForConditionAsync(() => completionCount >= 1, CombiningWaitTimeout); + await Assert.That(completionCount >= 1).IsTrue(); // Only one completion should have been forwarded await Assert.That(completionCount).IsEqualTo(1); @@ -345,7 +345,7 @@ public async Task WhenZipFirstSourceFails_ThenCompletesWithFailure() return default; }); await first.OnCompletedAsync(Result.Failure(new InvalidOperationException(FirstFailMessage))); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, CombiningWaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); await first.DisposeAsync(); @@ -419,7 +419,7 @@ public async Task WhenZipOnNextAfterDone_ThenReturnsEarly() // Complete source1 with failure (sets _done = true) await source1.OnCompletedAsync(Result.Failure(new InvalidOperationException("done"))); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, CombiningWaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); // Now emit on source2 - should be ignored because _done = true await source2.OnNextAsync("after", CancellationToken.None); @@ -450,7 +450,7 @@ public async Task WhenZipOnNext1AfterDone_ThenReturnsEarly() // Complete source2 with failure (sets _done = true) await source2.OnCompletedAsync(Result.Failure(new InvalidOperationException("done"))); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, CombiningWaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); // Now emit on source1 - should be ignored because _done = true await source1.OnNextAsync(Sentinel42, CancellationToken.None); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs index e52e1bac..76f0e3ad 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalBaseTests.cs @@ -9,10 +9,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// 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. +/// Tests concurrent notification of empty, single and multiple observer collections. public class ConcurrentSignalBaseTests { /// Value forwarded by the OnNext fan-out tests. @@ -75,15 +72,20 @@ public async Task WhenRelayNextAsyncSlowPath_ThenWhenAllForwarded() IntCapture a = new(); IntCapture b = new(); IntCapture c = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); ImmutableArray> observers = [ - MakeSlow(a), + MakeSlow(a, release.Task), MakeSync(b), - MakeSlow(c) + MakeSlow(c, release.Task) ]; - await Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default); + var forwarding = Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default); + await Assert.That(forwarding.IsCompleted).IsFalse(); + await Assert.That(b.Value).IsEqualTo(ForwardedValue); + release.SetResult(); + await forwarding; await Assert.That(a.Value).IsEqualTo(ForwardedValue); await Assert.That(b.Value).IsEqualTo(ForwardedValue); @@ -112,13 +114,18 @@ await Concurrent.ForwardOnErrorResumeConcurrently( ErrorCapture a = new(); ErrorCapture b = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); ImmutableArray> multi = [ - new CallbackWitnessAsync(static (_, _) => default, MakeErrorSlow(a)), + new CallbackWitnessAsync(static (_, _) => default, MakeErrorSlow(a, release.Task)), new CallbackWitnessAsync(static (_, _) => default, MakeErrorSync(b)) ]; InvalidOperationException multiError = new("multi"); - await Concurrent.ForwardOnErrorResumeConcurrently(multi, multiError, default); + var forwarding = Concurrent.ForwardOnErrorResumeConcurrently(multi, multiError, default); + await Assert.That(forwarding.IsCompleted).IsFalse(); + await Assert.That(b.Error).IsSameReferenceAs(multiError); + release.SetResult(); + await forwarding; await Assert.That(a.Error).IsSameReferenceAs(multiError); await Assert.That(b.Error).IsSameReferenceAs(multiError); } @@ -141,12 +148,17 @@ public async Task WhenForwardOnCompleted_ThenAllBranchesForward() ResultCapture a = new(); ResultCapture b = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); ImmutableArray> multi = [ - new CallbackWitnessAsync(static (_, _) => default, null, MakeCompletedSlow(a)), + new CallbackWitnessAsync(static (_, _) => default, null, MakeCompletedSlow(a, release.Task)), new CallbackWitnessAsync(static (_, _) => default, null, MakeCompletedSync(b)) ]; - await Concurrent.ForwardOnCompletedConcurrently(multi, Result.Success); + var forwarding = Concurrent.ForwardOnCompletedConcurrently(multi, Result.Success); + await Assert.That(forwarding.IsCompleted).IsFalse(); + await Assert.That(b.Result).IsEqualTo(Result.Success); + release.SetResult(); + await forwarding; await Assert.That(a.Result).IsEqualTo(Result.Success); await Assert.That(b.Result).IsEqualTo(Result.Success); } @@ -158,14 +170,19 @@ public async Task WhenSerialErrorBroadcastSlowPath_ThenRemainingObserversReceive { ErrorCapture a = new(); ErrorCapture b = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); ImmutableArray> observers = [ - new CallbackWitnessAsync(static (_, _) => default, MakeErrorSlow(a)), + new CallbackWitnessAsync(static (_, _) => default, MakeErrorSlow(a, release.Task)), new CallbackWitnessAsync(static (_, _) => default, MakeErrorSync(b)) ]; InvalidOperationException error = new("serial-error"); - await SerialBroadcastHelpers.BroadcastOnErrorResumeAsync(observers, error, default); + var forwarding = SerialBroadcastHelpers.BroadcastOnErrorResumeAsync(observers, error, default); + await Assert.That(forwarding.IsCompleted).IsFalse(); + await Assert.That(b.Error).IsNull(); + release.SetResult(); + await forwarding; await Assert.That(a.Error).IsSameReferenceAs(error); await Assert.That(b.Error).IsSameReferenceAs(error); @@ -178,13 +195,18 @@ public async Task WhenSerialCompletionBroadcastSlowPath_ThenRemainingObserversRe { ResultCapture a = new(); ResultCapture b = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); ImmutableArray> observers = [ - new CallbackWitnessAsync(static (_, _) => default, null, MakeCompletedSlow(a)), + new CallbackWitnessAsync(static (_, _) => default, null, MakeCompletedSlow(a, release.Task)), new CallbackWitnessAsync(static (_, _) => default, null, MakeCompletedSync(b)) ]; - await SerialBroadcastHelpers.BroadcastOnCompletedAsync(observers, Result.Success); + var forwarding = SerialBroadcastHelpers.BroadcastOnCompletedAsync(observers, Result.Success); + await Assert.That(forwarding.IsCompleted).IsFalse(); + await Assert.That(b.Result).IsNull(); + release.SetResult(); + await forwarding; await Assert.That(a.Result).IsEqualTo(Result.Success); await Assert.That(b.Result).IsEqualTo(Result.Success); @@ -248,14 +270,14 @@ private static CallbackWitnessAsync MakeSync(IntCapture capture) => return default; }); - /// Creates an OnNext observer that suspends before capturing — forces the slow path. + /// Creates an OnNext observer that waits for release before capturing. /// The capture sink. + /// The notification gate. /// An observer whose OnNextAsync completes asynchronously. - private static CallbackWitnessAsync MakeSlow(IntCapture capture) => + private static CallbackWitnessAsync MakeSlow(IntCapture capture, Task release) => new(async (x, _) => { - // Yielding guarantees the returned ValueTask is incomplete when the fan-out inspects it. - await Task.Yield(); + await release; capture.Value = x; }); @@ -269,13 +291,14 @@ private static Func MakeErrorSync(Error return default; }; - /// OnErrorResume handler that suspends before recording — forces the slow path. + /// Creates an error handler that waits for release before recording. /// The capture sink. + /// The notification gate. /// An OnErrorResume delegate. - private static Func MakeErrorSlow(ErrorCapture capture) => + private static Func MakeErrorSlow(ErrorCapture capture, Task release) => async (ex, _) => { - await Task.Yield(); + await release; capture.Error = ex; }; @@ -289,13 +312,14 @@ private static Func MakeCompletedSync(ResultCapture capture) return default; }; - /// OnCompleted handler that suspends before recording — forces the slow path. + /// Creates a completion handler that waits for release before recording. /// The capture sink. + /// The notification gate. /// An OnCompleted delegate. - private static Func MakeCompletedSlow(ResultCapture capture) => + private static Func MakeCompletedSlow(ResultCapture capture, Task release) => async r => { - await Task.Yield(); + await release; capture.Result = r; }; diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs index e5c64fdd..011c2212 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs @@ -2,6 +2,8 @@ // 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.Runtime.CompilerServices; + namespace ReactiveUI.Primitives.Async.Tests; /// @@ -34,5 +36,6 @@ protected override void QueueTask(Task task) => /// Runs a queued task on the pool thread that picked it up. /// The queued task. /// when the task was executed. + [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ExecuteQueued(Task task) => TryExecuteTask(task); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/DirectSource.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/DirectSource.cs index 399268ad..000c2596 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/DirectSource.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/DirectSource.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// An async observable source that stores observer references for direct method invocation in tests. -/// Subscriptions return no-op disposables so that external disposal does not tear down observer access. -/// This enables testing race-condition guards inside operators like CombineLatest. -/// +/// Retains subscribed observers for direct notification after disposal. /// The element type. internal sealed class DirectSource : IObservableAsync { diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableAsyncSlotTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableAsyncSlotTests.cs index c25aee67..3957bfb9 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableAsyncSlotTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableAsyncSlotTests.cs @@ -112,6 +112,24 @@ public async Task WhenAssignedTwice_ThenThrowsInvalidOperation() public async Task WhenDisposedSlotMarkerDisposed_ThenCompletesSilently() => await Assert.That(static async () => await DisposableAsyncSlot.DisposedSentinel.DisposeAsync()).ThrowsNothing(); + /// Verifies a stale observation retries against the current slot state. + /// Whether the slot closed after the observation. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenSwapObservationIsStale_ThenUsesCurrentSlot(bool closed) + { + RecordingAsyncDisposable previous = new(); + RecordingAsyncDisposable incoming = new(); + var slot = closed ? DisposableAsyncSlot.DisposedSentinel : previous; + await DisposableAsyncSlot.SwapObservedAsync(ref slot, incoming, null); + await Assert.That(incoming.DisposeCount).IsEqualTo(closed ? 1 : 0); + await Assert.That(previous.DisposeCount).IsEqualTo(closed ? 0 : 1); + await Assert.That(slot).IsSameReferenceAs(closed ? DisposableAsyncSlot.DisposedSentinel : incoming); + await DisposableAsyncSlot.DisposeAsync(ref slot); + } + /// Recording async disposable that counts disposals. private sealed class RecordingAsyncDisposable : IAsyncDisposable { diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.SingleAssignment.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.SingleAssignment.cs new file mode 100644 index 00000000..5a854360 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.SingleAssignment.cs @@ -0,0 +1,87 @@ +// 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.Runtime.CompilerServices; +using ReactiveUI.Primitives.Async.Disposables; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests single-assignment ownership and disposal. +public partial class DisposableTests +{ + /// Tests SingleAssignmentDisposableAsync disposes assigned. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAssignmentDisposableAsync_ThenDisposesAssigned() + { + SingleAssignmentDisposableAsync sad = new(); + StrongBox disposed = new(); + await sad.SetDisposableAsync(DisposableAsync.Create(disposed, static state => + { + state.Value = true; + return default; + })); + await Assert.That(sad.IsDisposed).IsFalse(); + await sad.DisposeAsync(); + await Assert.That(sad.IsDisposed).IsTrue(); + await Assert.That(disposed.Value).IsTrue(); + } + + /// Tests SingleAssignment dispose before set disposes immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAssignmentDisposableAsyncDisposeBeforeSet_ThenSetDisposedImmediately() + { + SingleAssignmentDisposableAsync sad = new(); + await sad.DisposeAsync(); + StrongBox disposed = new(); + await sad.SetDisposableAsync(DisposableAsync.Create(disposed, static state => + { + state.Value = true; + return default; + })); + await Assert.That(disposed.Value).IsTrue(); + } + + /// Tests SingleAssignment double set throws. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAssignmentDisposableAsyncDoubleSet_ThenThrowsInvalidOperation() + { + SingleAssignmentDisposableAsync sad = new(); + await sad.SetDisposableAsync(DisposableAsync.Empty); + await Assert.That(async () => await sad.SetDisposableAsync(DisposableAsync.Empty)) + .ThrowsExactly(); + } + + /// Tests SingleAssignment get before set returns null. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAssignmentDisposableAsyncGetBeforeSet_ThenReturnsNull() + { + SingleAssignmentDisposableAsync sad = new(); + await Assert.That(sad.GetDisposable()).IsNull(); + } + + /// Tests SingleAssignment get after dispose returns non-null. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAssignmentDisposableAsyncGetAfterDispose_ThenReturnsEmpty() + { + SingleAssignmentDisposableAsync sad = new(); + await sad.DisposeAsync(); + await Assert.That(sad.GetDisposable()).IsNotNull(); + } + + /// Tests SingleAssignment get after set returns assigned. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAssignmentDisposableAsyncGetAfterSet_ThenReturnsAssigned() + { + SingleAssignmentDisposableAsync sad = new(); + var original = DisposableAsync.Empty; + await sad.SetDisposableAsync(original); + await Assert.That(sad.GetDisposable()).IsSameReferenceAs(original); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs index 5637fc99..eb848fe7 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposableTests.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests for DisposableAsync, CompositeDisposableAsync, SingleAssignmentDisposableAsync, and SerialDisposableAsync. -public class DisposableTests +public partial class DisposableTests { /// Tests DisposableAsync.Empty dispose does nothing. /// A representing the asynchronous test operation. @@ -196,81 +196,6 @@ public async Task WhenCompositeDisposableAsyncIsDisposed_ThenReturnsFalse() await Assert.That(composite.IsDisposed).IsFalse(); } - /// Tests SingleAssignmentDisposableAsync disposes assigned. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAssignmentDisposableAsync_ThenDisposesAssigned() - { - SingleAssignmentDisposableAsync sad = new(); - StrongBox disposed = new(); - await sad.SetDisposableAsync(DisposableAsync.Create(disposed, static state => - { - state.Value = true; - return default; - })); - await Assert.That(sad.IsDisposed).IsFalse(); - await sad.DisposeAsync(); - await Assert.That(sad.IsDisposed).IsTrue(); - await Assert.That(disposed.Value).IsTrue(); - } - - /// Tests SingleAssignment dispose before set disposes immediately. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAssignmentDisposableAsyncDisposeBeforeSet_ThenSetDisposedImmediately() - { - SingleAssignmentDisposableAsync sad = new(); - await sad.DisposeAsync(); - StrongBox disposed = new(); - await sad.SetDisposableAsync(DisposableAsync.Create(disposed, static state => - { - state.Value = true; - return default; - })); - await Assert.That(disposed.Value).IsTrue(); - } - - /// Tests SingleAssignment double set throws. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAssignmentDisposableAsyncDoubleSet_ThenThrowsInvalidOperation() - { - SingleAssignmentDisposableAsync sad = new(); - await sad.SetDisposableAsync(DisposableAsync.Empty); - await Assert.That(async () => await sad.SetDisposableAsync(DisposableAsync.Empty)) - .ThrowsExactly(); - } - - /// Tests SingleAssignment get before set returns null. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAssignmentDisposableAsyncGetBeforeSet_ThenReturnsNull() - { - SingleAssignmentDisposableAsync sad = new(); - await Assert.That(sad.GetDisposable()).IsNull(); - } - - /// Tests SingleAssignment get after dispose returns non-null. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAssignmentDisposableAsyncGetAfterDispose_ThenReturnsEmpty() - { - SingleAssignmentDisposableAsync sad = new(); - await sad.DisposeAsync(); - await Assert.That(sad.GetDisposable()).IsNotNull(); - } - - /// Tests SingleAssignment get after set returns assigned. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAssignmentDisposableAsyncGetAfterSet_ThenReturnsAssigned() - { - SingleAssignmentDisposableAsync sad = new(); - var original = DisposableAsync.Empty; - await sad.SetDisposableAsync(original); - await Assert.That(sad.GetDisposable()).IsSameReferenceAs(original); - } - /// Tests SerialDisposableAsync replaces and disposes previous. /// A representing the asynchronous test operation. [Test] @@ -481,29 +406,34 @@ public async Task WhenSerialSetNullAfterDispose_ThenCompletesWithoutError() /// /// A representing the asynchronous test operation. [Test] - public async Task WhenSerialConcurrentSet_ThenAllPreviousDisposed() + public async Task WhenSerialReplacementPending_ThenFurtherReplacementDisposesCurrentValue() { + const int SecondValue = 2; + const int ThirdValue = 3; SingleReplaceableDisposableAsync serial = new(); - StrongBox disposedCount = new(); - - IAsyncDisposable MakeDisposable() => DisposableAsync.Create(disposedCount, static state => + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + List disposed = []; + await serial.SetDisposableAsync(DisposableAsync.Create((release, disposed), static async state => { - _ = Interlocked.Increment(ref state.Value); + await state.release.Task; + state.disposed.Add(1); + })); + var replacement = serial.SetDisposableAsync(DisposableAsync.Create(disposed, static items => + { + items.Add(SecondValue); return default; - }); - - const int ExpectedDisposedCount = 50; - - // Rapid concurrent sets to exercise the CAS retry path - var tasks = Enumerable.Range(0, ExpectedDisposedCount) - .Select(_ => Task.Run(async () => await serial.SetDisposableAsync(MakeDisposable()))); - await Task.WhenAll(tasks); - - // Dispose the serial to clean up the final remaining disposable + })); + await Assert.That(replacement.IsCompleted).IsFalse(); + await serial.SetDisposableAsync(DisposableAsync.Create(disposed, static items => + { + items.Add(ThirdValue); + return default; + })); await serial.DisposeAsync(); - - // All 50 disposables should eventually be disposed (49 replaced + 1 final) - await Assert.That(disposedCount.Value).IsEqualTo(ExpectedDisposedCount); + await Assert.That(disposed).IsCollectionEqualTo([SecondValue, ThirdValue]); + release.SetResult(); + await replacement; + await Assert.That(disposed).IsCollectionEqualTo([SecondValue, ThirdValue, 1]); } /// @@ -804,35 +734,35 @@ public void WhenToDisposableAsyncNull_ThenThrowsArgumentNull() => /// /// A representing the asynchronous test operation. [Test] - public async Task WhenSerialSetConcurrently_ThenAllDisposablesAccountedFor() + public async Task WhenSerialReplacementPending_ThenDisposalClosesTheSlot() { + const int SecondValue = 2; + const int ThirdValue = 3; SingleReplaceableDisposableAsync serial = new(); - StrongBox disposedCount = new(); - - IAsyncDisposable MakeDisposable() => DisposableAsync.Create(disposedCount, static state => + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + List disposed = []; + await serial.SetDisposableAsync(DisposableAsync.Create((release, disposed), static async state => { - _ = Interlocked.Increment(ref state.Value); + await state.release.Task; + state.disposed.Add(1); + })); + var replacement = serial.SetDisposableAsync(DisposableAsync.Create(disposed, static items => + { + items.Add(SecondValue); return default; - }); - - // Bounded contention: enough parallel sets to overlap on the CAS, few enough that the - // total disposal count stays small. The count below holds for every interleaving. - const int Parallelism = 4; - const int IterationsPerTask = 5; - const int ExpectedDisposedCount = Parallelism * IterationsPerTask; - var tasks = Enumerable.Range(0, Parallelism).Select(_ => Task.Run(async () => + })); + await Assert.That(replacement.IsCompleted).IsFalse(); + await serial.DisposeAsync(); + await serial.SetDisposableAsync(DisposableAsync.Create(disposed, static items => { - for (var i = 0; i < IterationsPerTask; i++) - { - await serial.SetDisposableAsync(MakeDisposable()); - } + items.Add(ThirdValue); + return default; })); - await Task.WhenAll(tasks); await serial.DisposeAsync(); - - // Every set disposable (Parallelism * IterationsPerTask) should be disposed — - // (Parallelism * IterationsPerTask - 1) replaced + 1 final dispose. - await Assert.That(disposedCount.Value).IsEqualTo(ExpectedDisposedCount); + await Assert.That(disposed).IsCollectionEqualTo([SecondValue, ThirdValue]); + release.SetResult(); + await replacement; + await Assert.That(disposed).IsCollectionEqualTo([SecondValue, ThirdValue, 1]); } /// Verifies that the shared disposed sentinel DisposeAsync returns a completed ValueTask without throwing. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/DisposalHelperTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposalHelperTests.cs new file mode 100644 index 00000000..6a68537c --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/DisposalHelperTests.cs @@ -0,0 +1,23 @@ +// 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.Primitives.Async.Helpers; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests disposal ownership claims. +public sealed class DisposalHelperTests +{ + /// Verifies that disposal ownership is granted only once. + /// A task representing the asynchronous test. + [Test] + public async Task WhenDisposalClaimedTwice_ThenSecondClaimFindsItDisposed() + { + var disposed = 0; + await Assert.That(DisposalHelper.HasDisposed(disposed)).IsFalse(); + await Assert.That(DisposalHelper.TrySetDisposed(ref disposed)).IsFalse(); + await Assert.That(DisposalHelper.HasDisposed(disposed)).IsTrue(); + await Assert.That(DisposalHelper.TrySetDisposed(ref disposed)).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs index 40ce0ccc..5713d6d1 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs @@ -14,12 +14,6 @@ public class ErrorHandlingOperatorTests /// Message of the resumable error raised by the source. private const string ResumeErrorMessage = "resume error"; - /// Maximum time a test waits for a completion or error to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// Window the retry test watches to confirm no completion is published. - private static readonly TimeSpan NoCompletionWindow = TimeSpan.FromMilliseconds(500); - /// Tests Catch with fallback switches to fallback. /// A representing the asynchronous test operation. [Test] @@ -81,7 +75,7 @@ public async Task WhenOnErrorResumeAsFailure_ThenConvertsErrorResumeToFailure() _ = completed.TrySetResult(); return default; }); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(errorSent).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); @@ -194,7 +188,7 @@ public async Task WhenCatchWithoutErrorResumeCallback_ThenForwardsToDownstream() }); InvalidOperationException expected = new("catch-passthrough"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -238,7 +232,7 @@ public async Task WhenRetryWithCountZero_ThenPropagatesErrorImmediately() _ = completed.TrySetResult(result); return default; }); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); await Assert.That(attempt).IsEqualTo(1); } @@ -265,7 +259,7 @@ public async Task WhenRetryCountExhausted_ThenPropagatesLastError() _ = completed.TrySetResult(result); return default; }); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); } @@ -290,7 +284,7 @@ public async Task WhenRetryWithCountOne_ThenRetriesOnceAndPropagates() _ = completed.TrySetResult(result); return default; }); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); } @@ -311,7 +305,7 @@ public async Task WhenCatchHandlerThrows_ThenCompletesWithHandlerException() _ = completed.TrySetResult(result); return default; }); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); await Assert.That(completionResult.Exception).IsTypeOf(); } @@ -331,7 +325,7 @@ public async Task WhenCatchDisposed_ThenDisposesSourceAndHandler() }); var sub = await source.Catch(_ => handlerObservable) .SubscribeAsync(static (_, _) => default, null, static _ => default); - await handlerItemReceived.Task.WaitAsync(WaitTimeout); + await handlerItemReceived.Task; // Disposing should dispose both source and handler disposables await sub.DisposeAsync(); @@ -364,9 +358,9 @@ public async Task WhenCatchHandlerDisposeThrows_ThenRoutedToUnhandled() }); var sub = await source.Catch(_ => handlerObservable) .SubscribeAsync(static (_, _) => default, null, static _ => default); - await handlerSubscribed.Task.WaitAsync(WaitTimeout); + await handlerSubscribed.Task; await sub.DisposeAsync(); - await unhandledTcs.Task.WaitAsync(WaitTimeout); + await unhandledTcs.Task; await Assert.That(unhandled).IsSameReferenceAs(disposeFailure); } finally @@ -425,11 +419,8 @@ public async Task WhenRetryResubscriptionCancelled_ThenSwallowsCancellation() return default; }); - // The OperationCanceledException is swallowed, so completion should not fire. - // Give a short window to verify no completion occurs. - var completedInTime = completed.Task.WaitAsync(NoCompletionWindow); const int ExpectedAttempts = 2; - await Assert.That(() => completedInTime).ThrowsExactly(); + await Assert.That(completed.Task.IsCompleted).IsFalse(); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); } @@ -460,7 +451,7 @@ public async Task WhenRetryResubscriptionThrows_ThenCompletesWithFailure() _ = completed.TrySetResult(result); return default; }); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); const int ExpectedAttempts = 2; await Assert.That(completionResult.Exception).IsTypeOf(); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs index 904c4162..f9fd33e3 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs @@ -2,9 +2,6 @@ // 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.Runtime.CompilerServices; -using ReactiveUI.Primitives.Async.Signals; - namespace ReactiveUI.Primitives.Async.Tests; /// @@ -14,172 +11,67 @@ namespace ReactiveUI.Primitives.Async.Tests; /// public class ExpireOperatorTests { - /// Seconds a test waits for a completion to arrive. - private const int WaitTimeoutSeconds = 5; - /// The inter-element deadline used by the tests. private static readonly TimeSpan DueTime = TimeSpan.FromMilliseconds(20); - /// Maximum time a test waits for a completion to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - - /// How long a test waits to prove a swallowed timer tick never produces a second completion. - private static readonly TimeSpan SecondTickSettleWindow = TimeSpan.FromMilliseconds(250); - /// Verifies that a source which never produces a value trips the deadline and the sequence completes with a . /// A representing the asynchronous test operation. [Test] public async Task WhenNoValueArrivesWithinTheDeadline_ThenCompletesWithTimeoutException() { - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await SignalAsync.Never().Expire(DueTime).SubscribeAsync( - static (_, _) => default, - null, - result => - { - _ = completed.TrySetResult(result); - return default; - }); - - var result = await completed.Task.WaitAsync(WaitTimeout); + ManualTimeProvider time = new(); + var pending = SignalAsync.Never().Timeout(DueTime, time).FirstAsync().AsTask(); + await time.FireNextAsync(); + await Assert.That(() => pending).ThrowsExactly(); + } - await Assert.That(result.IsFailure).IsTrue(); - await Assert.That(result.Exception is TimeoutException).IsTrue(); + /// Verifies that timer control becomes a no-op after disposal removes the timer. + /// A task representing the asynchronous test. + [Test] + public async Task WhenTimerDisposed_ThenRearmAndStopDoNothing() + { + ManualTimeProvider time = new(); + List completions = []; + CallbackWitnessAsync observer = new(static (_, _) => default, null, result => + { + completions.Add(result); + return default; + }); + SignalAsyncExtensions.TimeoutSignal.TimeoutWitness witness = new(observer, DueTime, time); + witness.StartTimer(CancellationToken.None); + var timer = await time.NextTimerAsync(); + witness.StopTimer(); + await Assert.That(timer.DueTime).IsEqualTo(Timeout.InfiniteTimeSpan); + witness.RearmTimer(); + await Assert.That(timer.DueTime).IsEqualTo(DueTime); + await witness.DisposeAsync(); + witness.RearmTimer(); + witness.StopTimer(); + timer.Fire(); + await Assert.That(completions).IsEmpty(); } - /// - /// Verifies that the deadline tick is swallowed once the sequence has already expired. A source value that - /// arrives after the timeout rearms the deadline, so the timer fires a second time; that tick must not turn - /// into a second for a sequence that is already finished. - /// + /// Verifies that rearming an expired deadline cannot produce a second completion. /// A representing the asynchronous test operation. [Test] public async Task WhenTheRearmedDeadlineTicksAfterExpiry_ThenNoSecondCompletionIsSent() { - RearmableTimeProvider timeProvider = new(); - var source = Signal.Create(); + ManualTimeProvider time = new(); List completions = []; - TaskCompletionSource firstCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); - - SignalAsyncExtensions.TimeoutSignal expiring = new(source.Values, DueTime, timeProvider); - - await using var sub = await expiring.SubscribeAsync( - static (_, _) => default, - null, - result => - { - completions.Add(result); - IgnoredResult.Of(firstCompletion.TrySetResult()); - return default; - }); - - timeProvider.FireAll(); - await firstCompletion.Task.WaitAsync(WaitTimeout); - + CallbackWitnessAsync observer = new(static (_, _) => default, null, result => + { + completions.Add(result); + return default; + }); + await using SignalAsyncExtensions.TimeoutSignal.TimeoutWitness witness = new(observer, DueTime, time); + witness.StartTimer(CancellationToken.None); + var timer = await time.NextTimerAsync(); + timer.Fire(); await Assert.That(completions).Count().IsEqualTo(1); - await Assert.That(completions[0].IsFailure).IsTrue(); - await Assert.That(completions[0].Exception is TimeoutException).IsTrue(); - - // The late value rearms the deadline; the rearmed tick has to be ignored. - await source.OnNextAsync(1, CancellationToken.None); - timeProvider.FireAll(); - - var secondArrived = await AsyncTestHelpers.WaitForConditionAsync( - () => completions.Count > 1, - SecondTickSettleWindow); - - await Assert.That(secondArrived).IsFalse(); + await witness.OnNextAsync(1, CancellationToken.None); + await Assert.That(timer.DueTime).IsEqualTo(DueTime); + timer.Fire(); await Assert.That(completions).Count().IsEqualTo(1); - } - - /// - /// A whose timers stay armed: invokes every live timer's - /// callback each time it is called, so a test can replay the tick of a deadline that the operator rearmed. - /// - private sealed class RearmableTimeProvider : TimeProvider - { - /// Protects timer collection access. - private readonly Lock _gate = new(); - - /// The timers created by this provider. - private readonly List _timers = []; - - /// Creates a timer that only fires when the test says so. - /// The callback to invoke when the timer is fired. - /// The state object passed to the callback. - /// The initial delay (ignored; the test drives the tick). - /// The interval (ignored; the test drives the tick). - /// A manually fired instance. - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - _ = dueTime; - _ = period; - RearmableTimer timer = new(callback, state); - lock (_gate) - { - _timers.Add(timer); - } - - return timer; - } - - /// Fires every timer this provider has handed out and has not seen disposed. - internal void FireAll() - { - RearmableTimer[] timers; - lock (_gate) - { - timers = [.. _timers]; - } - - foreach (var timer in timers) - { - timer.Fire(); - } - } - - /// A timer that can be fired any number of times until it is disposed. - /// The callback to invoke. - /// The state object passed to the callback. - private sealed class RearmableTimer(TimerCallback callback, object? state) : ITimer - { - /// Non-zero once the timer has been disposed. - private int _disposed; - - /// Reports whether the timer is still live; the new deadline is irrelevant to the test. - /// The due time (ignored). - /// The period (ignored). - /// when the timer is still active. - public bool Change(TimeSpan dueTime, TimeSpan period) - { - _ = dueTime; - _ = period; - return Volatile.Read(ref _disposed) == 0; - } - - /// Marks the timer as disposed. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Dispose() => Interlocked.Exchange(ref _disposed, 1); - - /// Marks the timer as disposed. - /// A completed . - public ValueTask DisposeAsync() - { - Dispose(); - return default; - } - - /// Invokes the callback unless the timer has been disposed. - internal void Fire() - { - if (Volatile.Read(ref _disposed) != 0) - { - return; - } - - callback(state); - } - } + await Assert.That(completions[0].Exception).IsTypeOf(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs index 045e69b5..1a5f4665 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs @@ -257,10 +257,13 @@ static async (observer, ct) => [Test] public async Task WhenTimerSingleShot_ThenEmitsSingleValueAfterDelay() { - var source = SignalAsync.Timer(SingleShotDelay); - var result = await source.ToListAsync(); - await Assert.That(result).Count().IsEqualTo(1); - await Assert.That(result[0]).IsEqualTo(0L); + ManualTimeProvider time = new(); + var pending = SignalAsync.Timer(SingleShotDelay, time).ToListAsync().AsTask(); + var timer = await time.NextTimerAsync(); + await Assert.That(pending.IsCompleted).IsFalse(); + await Assert.That(timer.DueTime).IsEqualTo(SingleShotDelay); + timer.Fire(); + await Assert.That(await pending).IsCollectionEqualTo([0L]); } /// Tests Timer periodic emits multiple values. @@ -268,25 +271,16 @@ public async Task WhenTimerSingleShot_ThenEmitsSingleValueAfterDelay() [Test] public async Task WhenTimerPeriodic_ThenEmitsMultipleValues() { - const int MinimumEmissions = 2; - var source = SignalAsync.Timer(PeriodicDueTime, PeriodicInterval); - List items = []; - TaskCompletionSource minimumReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await source.SubscribeAsync( - (x, _) => - { - items.Add(x); - if (items.Count >= MinimumEmissions) - { - IgnoredResult.Of(minimumReceived.TrySetResult()); - } - - return default; - }, - null); - await minimumReceived.Task; - await Assert.That(items.Count).IsGreaterThanOrEqualTo(MinimumEmissions); - await Assert.That(items[0]).IsEqualTo(0L); + const int SecondValue = 2; + ManualTimeProvider time = new(); + var pending = SignalAsync.Timer(PeriodicDueTime, PeriodicInterval, time).Take(SecondValue).ToListAsync().AsTask(); + var first = await time.NextTimerAsync(); + await Assert.That(first.DueTime).IsEqualTo(PeriodicDueTime); + first.Fire(); + var second = await time.NextTimerAsync(); + await Assert.That(second.DueTime).IsEqualTo(PeriodicInterval); + second.Fire(); + await Assert.That(await pending).IsCollectionEqualTo([0L, 1L]); } /// Tests Timer negative due time. @@ -370,46 +364,11 @@ public async Task WhenVoidTaskToAsyncSignal_ThenEmitsUnit() [Test] public async Task WhenIntervalWithCancellation_ThenEmitsPeriodicValues() { - const int MinimumEmissions = 2; - using CancellationTokenSource cts = new(); - var source = SignalAsync.Interval(PeriodicInterval); - List items = []; - TaskCompletionSource minimumReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - var received = false; - try - { - await using var sub = await source.SubscribeAsync( - (x, _) => - { - items.Add(x); - if (items.Count >= MinimumEmissions) - { - IgnoredResult.Of(minimumReceived.TrySetResult()); - } - - return default; - }, - null, - null, - cts.Token); - await minimumReceived.Task; - received = true; - } - catch (OperationCanceledException) - { - // Expected — the timer is being cancelled to end the test. - } - finally - { - if (!cts.IsCancellationRequested) - { - await cts.CancelAsync(); - } - } - - await Assert.That(received).IsTrue(); - await Assert.That(items.Count).IsGreaterThanOrEqualTo(MinimumEmissions); - await Assert.That(items[0]).IsEqualTo(1L); + const int SecondValue = 2; + const long SecondTick = 2L; + ManualTimeProvider time = new(); + var values = await time.RunAsync(SignalAsync.Interval(PeriodicInterval, time).Take(SecondValue).ToListAsync().AsTask()); + await Assert.That(values).IsCollectionEqualTo([1L, SecondTick]); } /// Tests that enumerable subscription emission returns early when the cancellation token is already cancelled. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/FilteringOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/FilteringOperatorTests.cs index cf0c6fce..c5b30876 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/FilteringOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/FilteringOperatorTests.cs @@ -46,9 +46,6 @@ public class FilteringOperatorTests /// Hoisted source array used by tests (was inline literal). private static readonly string[] SequenceAbcAbADefDe = ["abc", "ab", "a", "def", "de"]; - /// Maximum time a test waits for a forwarded error to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Tests sync Where filters elements. /// A representing the asynchronous test operation. [Test] @@ -383,7 +380,7 @@ public async Task WhenSkipWhileSyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("skip-while-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -407,7 +404,7 @@ public async Task WhenTakeWhileSyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("take-while-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -458,7 +455,7 @@ public async Task WhenSkipWhileAsyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("skip-while-async-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -480,7 +477,7 @@ public async Task WhenTakeWhileAsyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("take-while-async-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -501,7 +498,7 @@ public async Task WhenDistinctSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("distinct-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -523,7 +520,7 @@ public async Task WhenDistinctBySourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("distinct-by-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -545,7 +542,7 @@ public async Task WhenDistinctUntilChangedSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("distinct-until-changed-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -567,7 +564,7 @@ public async Task WhenDistinctUntilChangedBySourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("distinct-until-changed-by-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -590,7 +587,7 @@ public async Task WhenWhereSyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("where-sync-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -611,7 +608,7 @@ public async Task WhenSkipSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("skip-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -633,7 +630,7 @@ public async Task WhenTakeSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("take-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -655,7 +652,7 @@ public async Task WhenCastSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("cast-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -677,7 +674,7 @@ public async Task WhenOfTypeSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("of-type-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -700,7 +697,7 @@ public async Task WhenSelectSyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("select-sync-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -723,7 +720,7 @@ public async Task WhenSelectAsyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("select-async-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -746,7 +743,7 @@ public async Task WhenWhereAsyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("where-async-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/FireAndForgetHelperTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/FireAndForgetHelperTests.cs new file mode 100644 index 00000000..15af7a23 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/FireAndForgetHelperTests.cs @@ -0,0 +1,50 @@ +// 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.Primitives.Async.Helpers; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests detached action completion and exception reporting. +public sealed class FireAndForgetHelperTests +{ + /// Verifies that asynchronous action completion includes exception reporting. + /// Whether the released action throws. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenDetachedActionFinishes_ThenFailureIsReported(bool fail) + { + using UnhandledExceptionCapture capture = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + InvalidOperationException expected = new("action failed"); + var completed = false; + var pending = FireAndForgetHelper.RunAsync(async () => + { + await release.Task; + completed = true; + if (fail) + { + throw expected; + } + }); + await Assert.That(pending.IsCompleted).IsFalse(); + release.SetResult(); + await pending; + await Assert.That(completed).IsTrue(); + if (!fail) + { + return; + } + + await Assert.That(await capture.WaitForAsync(expected.Message)).IsSameReferenceAs(expected); + } + + /// Verifies that action validation rejects null delegates. + /// A task representing the asynchronous test. + [Test] + public async Task WhenDetachedActionIsNull_ThenValidationFails() => + await Assert.That(static async () => await FireAndForgetHelper.RunAsync(null!)).ThrowsExactly(); +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/FlatMapCoordinatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/FlatMapCoordinatorTests.cs index 3537d7f9..23ab1a61 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/FlatMapCoordinatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/FlatMapCoordinatorTests.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// Direct coverage for lifecycle paths. +/// Tests flat-map coordinator notification and disposal behavior. public class FlatMapCoordinatorTests { /// Sample integer value one. @@ -143,7 +143,7 @@ public async Task WhenAlreadyDisposed_ThenIgnoresInnerAndRoutesLateFailure() await coordinator.FinishAsync(Result.Success); await coordinator.FinishAsync(Result.Failure(expected)); - var captured = await capture.WaitForAsync(ex => ReferenceEquals(ex, expected), TimeSpan.FromSeconds(One)); + var captured = await capture.WaitForAsync(ex => ReferenceEquals(ex, expected)); await Assert.That(ignored.SubscribeCount).IsEqualTo(0); await Assert.That(observer.Completions.Count).IsEqualTo(0); await Assert.That(captured).IsSameReferenceAs(expected); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/Internals/PooledDelaySourceTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/Internals/PooledDelaySourceTests.cs index 7b576939..1e81318b 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/Internals/PooledDelaySourceTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/Internals/PooledDelaySourceTests.cs @@ -10,9 +10,6 @@ public class PooledDelaySourceTests /// The delay used by the happy-path test. private static readonly TimeSpan ShortDelay = TimeSpan.FromMilliseconds(20); - /// Lower bound the elapsed time must clear to prove the delay was actually awaited. - private static readonly TimeSpan MinimumObservedDelay = TimeSpan.FromMilliseconds(5); - /// A delay long enough that it can only end through cancellation. private static readonly TimeSpan DelayOutlivingTheTest = TimeSpan.FromSeconds(10); @@ -24,7 +21,7 @@ public async Task WhenPreCancelledToken_ThenFailsWithOperationCanceled() using CancellationTokenSource cts = new(); await cts.CancelAsync(); var source = PooledDelaySource.Rent(); - var task = source.BeginAsync(ShortDelay, new NonSystemTimeProvider(), cts.Token); + var task = source.BeginAsync(ShortDelay, new ManualTimeProvider(), cts.Token); var ex = await Assert.That(async () => await task).ThrowsExactly(); await Assert.That(ex).IsNotNull(); } @@ -35,12 +32,26 @@ public async Task WhenPreCancelledToken_ThenFailsWithOperationCanceled() public async Task WhenTimerFires_ThenSourceCompletes() { var source = PooledDelaySource.Rent(); - var start = TimeProvider.System.GetTimestamp(); - await source.BeginAsync(ShortDelay, new NonSystemTimeProvider(), CancellationToken.None); - var elapsed = TimeProvider.System.GetElapsedTime(start); + ManualTimeProvider time = new(); + var pending = source.BeginAsync(ShortDelay, time, CancellationToken.None); + var timer = await time.NextTimerAsync(); + await Assert.That(pending.IsCompleted).IsFalse(); + await Assert.That(timer.DueTime).IsEqualTo(ShortDelay); + timer.Fire(); + await pending; + } - // Verify the delay actually happened — the source's timer-fired path completed it. - await Assert.That(elapsed).IsGreaterThanOrEqualTo(MinimumObservedDelay); + /// A callback fired during timer creation completes before cancellation registration. + /// A task representing the asynchronous test. + [Test] + public async Task WhenTimerFiresDuringCreation_ThenCompletesSynchronously() + { + using CancellationTokenSource cancellation = new(); + var source = PooledDelaySource.Rent(); + var pending = source.BeginAsync(ShortDelay, new ImmediateTimeProvider(), cancellation.Token); + await Assert.That(pending.IsCompletedSuccessfully).IsTrue(); + await cancellation.CancelAsync(); + await pending; } /// Verifies that a token cancelled mid-flight propagates an . @@ -50,17 +61,47 @@ public async Task WhenTokenCancelledMidFlight_ThenFailsWithOperationCanceled() { using CancellationTokenSource cts = new(); var source = PooledDelaySource.Rent(); - var task = source.BeginAsync(DelayOutlivingTheTest, new NonSystemTimeProvider(), cts.Token); + var task = source.BeginAsync(DelayOutlivingTheTest, new ManualTimeProvider(), cts.Token); await cts.CancelAsync(); var ex = await Assert.That(async () => await task).ThrowsExactly(); await Assert.That(ex).IsNotNull(); } - /// Non-System that forces BeginAsync down the non-System path. - private sealed class NonSystemTimeProvider : TimeProvider + /// The first terminal event owns the result even when the second event runs before consumption. + /// Whether cancellation precedes the timer callback. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenTimerAndCancellationBothFire_ThenFirstEventWins(bool cancelFirst) + { + ManualTimeProvider time = new(); + using CancellationTokenSource cancellation = new(); + var source = PooledDelaySource.Rent(); + var pending = source.BeginAsync(ShortDelay, time, cancellation.Token); + var timer = await time.NextTimerAsync(); + if (cancelFirst) + { + await cancellation.CancelAsync(); + timer.Fire(); + await Assert.That(async () => await pending).ThrowsExactly(); + } + else + { + timer.Fire(); + await cancellation.CancelAsync(); + await pending; + } + } + + /// A provider that invokes its callback inside timer creation. + private sealed class ImmediateTimeProvider : TimeProvider { /// - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => - System.CreateTimer(callback, state, dueTime, period); + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + callback(state); + return new ManualTimeProvider.ManualTimer(callback, state, dueTime); + } } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs index 5b69b25e..a44c2a24 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs @@ -14,52 +14,30 @@ public class IntervalOperatorTests /// The tick the handler disposes on. private const long DisposeOnTick = 2; - /// Seconds a test waits for the reentrant dispose to finish. - private const int WaitTimeoutSeconds = 5; - /// The interval between ticks. private static readonly TimeSpan TickPeriod = TimeSpan.FromMilliseconds(20); - /// Maximum time a test waits for the reentrant dispose to finish. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - - /// How long a test waits afterwards to prove no further tick is emitted. - private static readonly TimeSpan QuietWindow = TimeSpan.FromMilliseconds(200); - /// Verifies that disposing the interval subscription from inside a tick handler ends the tick loop: /// the ticks seen so far start at one and are consecutive, and nothing arrives after the dispose. /// A representing the asynchronous test operation. [Test] public async Task WhenDisposedFromWithinATick_ThenTheTickLoopStops() { - TaskCompletionSource handleReady = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + const long SecondTick = 2L; + ManualTimeProvider time = new(); List ticks = []; - - var subscription = await SignalAsync.Interval(TickPeriod).SubscribeAsync(async (tick, _) => + IAsyncDisposable? handle = null; + TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + handle = await SignalAsync.Interval(TickPeriod, time).SubscribeAsync(async (tick, _) => { ticks.Add(tick); - if (tick < DisposeOnTick) + if (tick == DisposeOnTick) { - return; + await handle!.DisposeAsync(); + disposed.SetResult(); } - - var handle = await handleReady.Task.ConfigureAwait(false); - await handle.DisposeAsync().ConfigureAwait(false); - IgnoredResult.Of(disposed.TrySetResult()); }); - - handleReady.SetResult(subscription); - await disposed.Task.WaitAsync(WaitTimeout); - - var ticksAtDispose = ticks.Count; - var keptTicking = await AsyncTestHelpers.WaitForConditionAsync( - () => ticks.Count > ticksAtDispose, - QuietWindow); - - await Assert.That(keptTicking).IsFalse(); - await Assert.That(ticks).Count().IsEqualTo(ticksAtDispose); - await Assert.That(ticks[0]).IsEqualTo(1L); - await Assert.That(ticks[^1]).IsEqualTo(DisposeOnTick); + await time.RunAsync(disposed.Task); + await Assert.That(ticks).IsCollectionEqualTo([1L, SecondTick]); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ManualTimeProvider.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ManualTimeProvider.cs new file mode 100644 index 00000000..471587de --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ManualTimeProvider.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using System.Threading.Channels; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Queues timer callbacks until the test explicitly advances them. +internal sealed class ManualTimeProvider : TimeProvider +{ + /// Pending timer registrations in creation order. + private readonly Channel _timers = Channel.CreateUnbounded(); + + /// Gets the number of registrations not yet consumed by the test. + internal int PendingTimerCount => _timers.Reader.Count; + + /// + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + ManualTimer timer = new(callback, state, dueTime); + _ = _timers.Writer.TryWrite(timer); + return timer; + } + + /// Waits for the next timer registration without advancing it. + /// The registered timer. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal ValueTask NextTimerAsync() => _timers.Reader.ReadAsync(); + + /// Fires the next registered timer. + /// A task representing the callback invocation. + internal async Task FireNextAsync() + { + var timer = await NextTimerAsync(); + timer.Fire(); + } + + /// Drives queued timers until the supplied operation completes. + /// The operation whose timers should run. + /// A task representing the operation. + internal async Task RunAsync(Task operation) + { + while (!operation.IsCompleted) + { + var ready = _timers.Reader.WaitToReadAsync().AsTask(); + if (await Task.WhenAny(operation, ready) == operation) + { + break; + } + + if (_timers.Reader.TryRead(out var timer)) + { + timer.Fire(); + } + } + + await operation; + } + + /// Drives queued timers until the supplied result is available. + /// The result type. + /// The operation whose timers should run. + /// The completed result. + internal async Task RunAsync(Task operation) + { + await RunAsync((Task)operation); + return await operation; + } + + /// A timer whose callback is invoked explicitly by its owner. + /// The timer callback. + /// The callback state. + /// The initial due time. + internal sealed class ManualTimer(TimerCallback callback, object? state, TimeSpan dueTime) : ITimer + { + /// Whether the timer has been disposed. + private bool _disposed; + + /// Gets the most recently requested due time. + internal TimeSpan DueTime { get; private set; } = dueTime; + + /// + public bool Change(TimeSpan dueTime, TimeSpan period) + { + DueTime = dueTime; + return !_disposed; + } + + /// + public void Dispose() => _disposed = true; + + /// + public ValueTask DisposeAsync() + { + Dispose(); + return default; + } + + /// Fires an armed callback once; a changed timer can be fired again. + internal void Fire() + { + if (_disposed || DueTime == Timeout.InfiniteTimeSpan) + { + return; + } + + DueTime = Timeout.InfiniteTimeSpan; + callback(state); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs index 4f2d77d1..0eb0e4bf 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs @@ -16,9 +16,6 @@ public class ObserveOnAsyncSignalTests /// Single sentinel emitted by the happy-path tests. private const int Sentinel = 7; - /// Maximum time a test waits for a forwarded notification to arrive. - private static readonly TimeSpan ForwardTimeout = TimeSpan.FromSeconds(5); - /// Verifies the forceYielding: true overload forwards values via the context-switching slow path. /// A representing the asynchronous test operation. [Test] @@ -169,7 +166,7 @@ public async Task WhenForceYieldingSourceEmitsResumableError_ThenSlowPathForward InvalidOperationException expected = new("observeon-resume"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(ForwardTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -186,7 +183,7 @@ public async Task WhenForwardAfterContextSwitchAsyncInvokedDirectly_ThenValueFor await sut.ForwardAfterContextSwitchAsync(Sentinel, CancellationToken.None); - var received = await captured.Task.WaitAsync(ForwardTimeout); + var received = await captured.Task; await Assert.That(received).IsEqualTo(Sentinel); } @@ -202,7 +199,7 @@ public async Task WhenForwardErrorAfterContextSwitchAsyncInvokedDirectly_ThenErr await sut.ForwardErrorAfterContextSwitchAsync(expected, CancellationToken.None); - var received = await captured.Task.WaitAsync(ForwardTimeout); + var received = await captured.Task; await Assert.That(received).IsSameReferenceAs(expected); } @@ -217,7 +214,7 @@ public async Task WhenForwardCompletionAfterContextSwitchAsyncInvokedDirectly_Th await sut.ForwardCompletionAfterContextSwitchAsync(Result.Success); - var result = await captured.Task.WaitAsync(ForwardTimeout); + var result = await captured.Task; await Assert.That(result.IsSuccess).IsTrue(); } @@ -240,7 +237,7 @@ public async Task WhenContextSwitchSignalSubscribed_ThenValueForwarded() await signal.OnNextAsync(Sentinel, CancellationToken.None); - var value = await received.Task.WaitAsync(ForwardTimeout); + var value = await received.Task; await Assert.That(value).IsEqualTo(Sentinel); } @@ -267,7 +264,7 @@ public async Task WhenContextSwitchSignalSourceEmitsResumableError_ThenErrorForw InvalidOperationException expected = new("context-switch-resume"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(ForwardTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs index ae1a4e6a..a8fa65bc 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserverAsyncDisposeTests.cs @@ -2,6 +2,7 @@ // 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.Runtime.CompilerServices; using ReactiveUI.Primitives.Async.Disposables; namespace ReactiveUI.Primitives.Async.Tests; @@ -9,46 +10,45 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests for disposal behavior. public sealed class ObserverAsyncDisposeTests { - /// The value emitted by the hopping source. + /// The value emitted by the asynchronous source. private const int EmittedValue = 7; /// The value pushed after the external link has been cancelled; it must never be delivered. private const int PostCancellationValue = 8; - /// - /// How many times the dispose-versus-exiting-notification race is replayed. The interesting interleaving — - /// the in-flight call count reaching zero between the disposer reading it and re-reading it after publishing - /// its wait handle — is a nanosecond-wide window, so it is provoked repeatedly rather than once. - /// - private const int DisposeRaceAttempts = 256; - - /// Verifies the reentrant dispose path lets an observer dispose itself from within its own in-flight - /// notification without deadlocking, even after the notification continuation has hopped to a different thread. - /// A task that completes when disposal finishes; a self-join deadlock leaves it pending. + /// Verifies that a resumed notification can dispose its own observer. + /// A task representing the asynchronous test. [Test] - public async Task WhenDisposedReentrantlyFromOwnNotificationAfterThreadHop_ThenDoesNotDeadlock() + public async Task WhenDisposedReentrantlyAfterNotificationResumes_ThenCompletes() { - SelfDisposingObserver observer = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + SelfDisposingObserver observer = new(release.Task); - await observer.OnNextAsync(1, CancellationToken.None); + var notification = observer.OnNextAsync(1, CancellationToken.None); + await Assert.That(notification.IsCompleted).IsFalse(); + release.SetResult(); + await notification; await Assert.That(observer.HasDisposed).IsTrue(); } - /// Verifies the terminal-sink reentrant dispose path completes when the result resolves during a - /// notification whose continuation has hopped threads. + /// Verifies that FirstAsync resolves and disposes its source after a suspended subscription resumes. /// A task to monitor completion. [Test] - public async Task WhenFirstAsyncResolvesDuringHoppedNotification_ThenCompletes() + public async Task WhenFirstAsyncResolvesAfterSubscriptionResumes_ThenCompletes() { - var source = SignalAsync.Create(static async (observer, _) => + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + var source = SignalAsync.Create(async (observer, _) => { - await Task.Yield(); + await release.Task; await observer.OnNextAsync(EmittedValue, CancellationToken.None).ConfigureAwait(false); return DisposableAsync.Empty; }); - var value = await source.FirstAsync(); + var first = source.FirstAsync(); + await Assert.That(first.IsCompleted).IsFalse(); + release.SetResult(); + var value = await first; await Assert.That(value).IsEqualTo(EmittedValue); } @@ -75,25 +75,126 @@ public async Task WhenLinkedToItsOwnDisposeToken_ThenExternalLinkStillDisposesTh await Assert.That(observer.Received).IsCollectionEqualTo([EmittedValue]); } - /// Verifies that disposing an observer from one thread while a notification is still in flight on - /// another never hangs, including when that notification's call count drops to zero inside the disposer's - /// publish-then-recheck window — the case the disposer must self-signal to avoid waiting forever. - /// A task that completes when every attempt has disposed; a deadlocked wait leaves it pending. + /// Verifies that the last active notification signals an already published disposal waiter. + /// A task representing the asynchronous test. + [Test] + public async Task WhenCompletionWaiterPublishedBeforeNotificationExits_ThenExitSignalsWaiter() + { + RecordingObserver observer = new(CancellationToken.None); + var entered = observer.TryEnterOnSomethingCall(CancellationToken.None, out var scope); + await Assert.That(entered).IsTrue(); + var waiter = observer.PublishCallCompletionWaiter(); + await Assert.That(waiter.IsCompleted).IsFalse(); + scope.Dispose(); + await Assert.That(observer.ExitOnSomethingCall()).IsFalse(); + await waiter; + await observer.DisposeAsync(); + await Assert.That(observer.HasDisposed).IsTrue(); + } + + /// A notification that exits before waiter publication requires no further signal. + /// A task representing the asynchronous test. [Test] - public async Task WhenDisposedFromAnotherThreadAsNotificationExits_ThenDoesNotDeadlock() + public async Task WhenNotificationExitsBeforeCompletionWaiterPublished_ThenWaiterIsCompleted() { - for (var attempt = 0; attempt < DisposeRaceAttempts; attempt++) + RecordingObserver observer = new(CancellationToken.None); + var entered = observer.TryEnterOnSomethingCall(CancellationToken.None, out var scope); + await Assert.That(entered).IsTrue(); + scope.Dispose(); + await Assert.That(observer.ExitOnSomethingCall()).IsTrue(); + var waiter = observer.PublishCallCompletionWaiter(); + await Assert.That(waiter.IsCompletedSuccessfully).IsTrue(); + await waiter; + await observer.DisposeAsync(); + } + + /// Disposal waits for a notification owned by another thread before releasing its source subscription. + /// A task representing the asynchronous test. + [Test] + public async Task WhenDisposedWithForeignNotificationOwner_ThenSourceWaitsForNotificationExit() + { + const int ForeignOwnerThread = -1; + RecordingObserver observer = new(CancellationToken.None); + StrongBox sourceDisposed = new(); + await observer.AssignSourceSubscriptionAsync(DisposableAsync.Create(sourceDisposed, static state => { - ParkedNotificationObserver observer = new(); - var notification = Task.Run(async () => - await observer.OnNextAsync(EmittedValue, CancellationToken.None)); + state.Value = true; + return default; + })); + var entered = observer.TryEnterOnSomethingCall(ForeignOwnerThread, CancellationToken.None, out var scope); + await Assert.That(entered).IsTrue(); + + var disposal = observer.DisposeAsync(); + await Assert.That(disposal.IsCompleted).IsFalse(); + await Assert.That(sourceDisposed.Value).IsFalse(); + scope.Dispose(); + await Assert.That(observer.ExitOnSomethingCall()).IsFalse(); + await disposal; + + await Assert.That(sourceDisposed.Value).IsTrue(); + await Assert.That(observer.HasDisposed).IsTrue(); + } - await observer.Entered; - await observer.DisposeAsync(); - await notification; + /// Verifies entry and exit retry against the current notification count after stale observations. + /// A task representing the asynchronous test. + [Test] + public async Task WhenCallStateChangesAfterObservation_ThenEntryAndExitRetry() + { + const int CallerThread = 1; + await using RecordingObserver observer = new(CancellationToken.None); + var empty = observer.ReadCallState(); + var enteredFirst = observer.TryEnterOnSomethingCall(CallerThread, CancellationToken.None, out var firstScope); + var beforeSecondEntry = observer.ReadCallState(); + var enteredSecond = observer.TryEnterObservedCallState(CallerThread, empty, CancellationToken.None, out var secondScope); + var waiter = observer.PublishCallCompletionWaiter(); + firstScope.Dispose(); + secondScope.Dispose(); + await Assert.That(enteredFirst).IsTrue(); + await Assert.That(enteredSecond).IsTrue(); + await Assert.That(observer.ExitObservedCallState(beforeSecondEntry)).IsTrue(); + await Assert.That(waiter.IsCompleted).IsFalse(); + await Assert.That(observer.ExitOnSomethingCall()).IsFalse(); + await waiter; + } - await Assert.That(observer.HasDisposed).IsTrue(); + /// Verifies that competing source publications retain one source and preserve prior disposal. + /// Whether disposal precedes source publication. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenDisposeSourcePublishedTwice_ThenFirstSourceIsRetained(bool disposeFirst) + { + RecordingObserver observer = new(CancellationToken.None); + if (disposeFirst) + { + await observer.DisposeAsync(); } + + var first = observer.MaterializeDisposeCts(); + var second = observer.MaterializeDisposeCts(); + await Assert.That(second).IsSameReferenceAs(first); + await Assert.That(first.IsCancellationRequested).IsEqualTo(disposeFirst); + await observer.DisposeAsync(); + } + + /// Verifies that the last call either signals the disposal waiter or performs disposal itself. + /// Whether another caller is waiting for disposal. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenLastCallCompletes_ThenDisposalFollowsWaiterOwnership(bool publishWaiter) + { + RecordingObserver observer = new(CancellationToken.None); + var entered = observer.TryEnterOnSomethingCall(CancellationToken.None, out var scope); + await Assert.That(entered).IsTrue(); + var waiter = publishWaiter ? observer.PublishCallCompletionWaiter() : Task.CompletedTask; + scope.Dispose(); + await observer.CompleteOrChainDispose(); + await waiter; + await Assert.That(observer.HasDisposed).IsEqualTo(!publishWaiter); + await observer.DisposeAsync(); } /// Observer that records every value it is handed, constructed with an external dispose link. @@ -118,49 +219,14 @@ protected override ValueTask OnErrorResumeAsyncCore(Exception error, Cancellatio protected override ValueTask OnCompletedAsyncCore(Result result) => default; } - /// Observer whose notification stays in flight until disposal releases it, so the call exits while the - /// disposer is inside its publish-then-recheck window rather than long before or long after it. - private sealed class ParkedNotificationObserver : WitnessAsync - { - /// Completes once the notification has been entered and the in-flight call count is non-zero. - private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Completes when disposal releases the parked notification. - private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Gets a task that completes once the notification is in flight. - internal Task Entered => _entered.Task; - - /// - protected override async ValueTask OnNextAsyncCore(int value, CancellationToken cancellationToken) - { - IgnoredResult.Of(_entered.TrySetResult()); - await _released.Task.ConfigureAwait(false); - } - - /// - protected override ValueTask OnErrorResumeAsyncCore(Exception error, CancellationToken cancellationToken) => - default; - - /// - protected override ValueTask OnCompletedAsyncCore(Result result) => default; - - /// - protected override ValueTask DisposeAsyncCore() - { - // Release before the base waits on in-flight calls: the notification exits as the gate is published. - IgnoredResult.Of(_released.TrySetResult()); - return base.DisposeAsyncCore(); - } - } - - /// Observer whose OnNext hops threads then disposes itself via the reentrant path. - private sealed class SelfDisposingObserver : WitnessAsync + /// Observer that disposes itself when a suspended notification resumes. + /// The notification gate. + private sealed class SelfDisposingObserver(Task release) : WitnessAsync { /// protected override async ValueTask OnNextAsyncCore(int value, CancellationToken cancellationToken) { - await Task.Yield(); + await release; await ((IReentrantAsyncDisposable)this).DisposeFromNotificationAsync().ConfigureAwait(false); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersFilterFusionsTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersFilterFusionsTests.cs index 17611cd3..279ce69a 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersFilterFusionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersFilterFusionsTests.cs @@ -6,15 +6,9 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// 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. +/// Tests filtering and error forwarding in fused async operators. public class ParityHelpersFilterFusionsTests { - /// Seconds a test waits for a notification before giving up. - private const int WaitTimeoutSeconds = 5; - /// Sentinel "found" sentinel string. private const string Hit = "hit"; @@ -33,9 +27,6 @@ public class ParityHelpersFilterFusionsTests /// Three-element ascending source sequence. private static readonly int[] Sequence123 = [1, 2, 3]; - /// Maximum time a test waits for a forwarded notification to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - /// Verifies that SkipWhileNull drops leading nulls then forwards every value. /// A representing the asynchronous test operation. [Test] @@ -47,11 +38,7 @@ public async Task WhenSkipWhileNull_ThenDropsLeadingNullsThenLatches() .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"); + await Assert.That(result).IsCollectionEqualTo(["a", null!, "b"]); } /// Verifies that WhereIsNotNull strips nulls and forwards non-nulls. @@ -196,9 +183,7 @@ public async Task WhenWhereIsNotNullSourceErrorResume_ThenForwarded() await signal.OnErrorResumeAsync(new InvalidOperationException(Hit), CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync( - () => received is not null, - WaitTimeout); + await Assert.That(received is not null).IsTrue(); await Assert.That(received).IsNotNull(); await Assert.That(received!.Message).IsEqualTo(Hit); @@ -225,7 +210,7 @@ public async Task WhenPairwiseSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("pairwise-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -250,7 +235,7 @@ public async Task WhenSkipWhileNullSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("skip-while-null-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -275,7 +260,7 @@ public async Task WhenLatestOrDefaultSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("latest-or-default-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -300,7 +285,7 @@ public async Task WhenWaitUntilSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("wait-until-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -325,7 +310,7 @@ public async Task WhenAsSignalSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("as-signal-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -350,7 +335,7 @@ public async Task WhenAsyncNotSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("not-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -375,7 +360,7 @@ public async Task WhenWhereTrueSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("where-true-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -400,7 +385,7 @@ public async Task WhenWhereFalseSourceErrorResume_ThenForwarded() InvalidOperationException expected = new("where-false-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs index 3cac112f..7876f024 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityHelpersOperatorFusionsTests.cs @@ -8,16 +8,9 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// 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). +/// Tests filtering, accumulation and flattening in fused async operators. public class ParityHelpersOperatorFusionsTests { - /// Seconds a test waits for a notification before giving up. - private const int WaitTimeoutSeconds = 5; - /// Message thrown by a downstream observer in the scan fusion tests. private const string DownstreamThrowsMessage = "downstream-throws"; @@ -48,15 +41,12 @@ public class ParityHelpersOperatorFusionsTests /// Sentinel four. private const int Four = 4; - /// Debounce window long enough that only the condition can release a value. + /// Debounce window used by manually fired bypass tests. private static readonly TimeSpan DebounceWindow = TimeSpan.FromHours(1); - /// Debounce window short enough that the test can wait for it to elapse. + /// Debounce window used by manually fired delay tests. private static readonly TimeSpan ShortDebounceWindow = TimeSpan.FromMilliseconds(80); - /// Maximum time a test waits for a notification to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - /// Array sentinels for the array fast-path test. private static readonly int[] ArraySlice1 = [One, Two]; @@ -110,63 +100,37 @@ public async Task WhenScanWithInitialAsync_ThenSeedThenAsyncFolded() [Test] public async Task WhenThrottleDistinctConsecutiveDuplicates_ThenSuppressesUpstream() { + ManualTimeProvider time = new(); var result = await ThrottleDuplicateInputs.ToAsyncSignal() - .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), time) .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); + await Assert.That(result).IsEmpty(); + await Assert.That(time.PendingTimerCount).IsEqualTo(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".) + /// Verifies that completion suppresses pending distinct values. /// A representing the asynchronous test operation. [Test] public async Task WhenThrottleDistinctRapidDistinctValues_ThenNoConsecutiveDuplicates() { var result = await ThrottleRapidInputs.ToAsyncSignal() - .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 the time-provider overload of ThrottleDistinct keeps the - /// upstream duplicate suppression when an explicit provider is supplied. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenThrottleDistinctWithTimeProviderAndConsecutiveDuplicates_ThenSuppressesUpstream() - { - var result = await ThrottleDuplicateInputs.ToAsyncSignal() - .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), TimeProvider.System) + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), new ManualTimeProvider()) .ToListAsync(); - await Assert.That(result.Count).IsLessThanOrEqualTo(1); + await Assert.That(result).IsEmpty(); } - /// Verifies that the time-provider overload of ThrottleDistinct falls back to the - /// system provider when the caller passes , and still throttles. + /// Verifies that a null provider supports a zero throttle window. /// A representing the asynchronous test operation. [Test] public async Task WhenThrottleDistinctWithNullTimeProvider_ThenStillThrottles() { var result = await ThrottleRapidInputs.ToAsyncSignal() - .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), null) + .ThrottleDistinct(TimeSpan.Zero, null) .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]); - } + await Assert.That(result).IsCollectionEqualTo(ThrottleRapidInputs); } /// Verifies that DebounceUntil with an always-true condition bypasses the debounce window and emits inline. @@ -188,7 +152,7 @@ public async Task WhenDebounceUntilConditionAlwaysTrue_ThenEmitsImmediately() public async Task WhenDebounceUntilWithTimeProviderAndConditionAlwaysTrue_ThenEmitsImmediately() { var result = await DebounceInputs.ToAsyncSignal() - .DebounceUntil(DebounceWindow, static _ => true, TimeProvider.System) + .DebounceUntil(DebounceWindow, static _ => true, new ManualTimeProvider()) .ToListAsync(); await Assert.That(result).IsCollectionEqualTo(DebounceInputs); @@ -390,7 +354,7 @@ public async Task WhenThrottleDistinctSourceErrorResumes_ThenForwardsDownstream( TaskCompletionSource errorTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await signal.Values - .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), new ManualTimeProvider()) .SubscribeAsync( static (_, _) => default, (ex, _) => @@ -417,7 +381,7 @@ public async Task WhenDebounceUntilSourceErrorResumes_ThenForwardsDownstream() TaskCompletionSource errorTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await signal.Values - .DebounceUntil(DebounceWindow, static _ => false) + .DebounceUntil(DebounceWindow, static _ => false, new ManualTimeProvider()) .SubscribeAsync( static (_, _) => default, (ex, _) => @@ -488,43 +452,32 @@ public async Task WhenDropIfBusySourceErrorResumes_ThenForwardsDownstream() 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. + /// Verifies that a pending downstream notification drops overlapping values and releases the busy flag on completion. /// A representing the asynchronous test operation. [Test] public async Task WhenDropIfBusySyncActionAsyncDownstream_ThenAwaitForwardSlowPathResets() { - var signal = Signal.Create(); List values = []; - TaskCompletionSource emittedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await signal.Values - .DropIfBusy(static (_, _) => default) - .SubscribeAsync(async (v, _) => - { - await Task.Yield(); - values.Add(v); - IgnoredResult.Of(emittedTcs.TrySetResult(v)); - }); - - await signal.OnNextAsync(One, CancellationToken.None); - await emittedTcs.Task; - - // After the slow path resets _isBusy, a second emission must also flow through. - TaskCompletionSource secondTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub2 = await signal.Values - .DropIfBusy(static (_, _) => default) - .SubscribeAsync(async (_, _) => - { - await Task.Yield(); - _ = secondTcs.TrySetResult(); - }); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + CallbackWitnessAsync downstream = new(async (value, _) => + { + await release.Task; + values.Add(value); + }); + await using SignalAsyncExtensions.DropIfBusySignal.DropIfBusyWitness witness = new( + downstream, + static (_, _) => default, + CancellationToken.None); - await signal.OnNextAsync(Two, CancellationToken.None); - await secondTcs.Task; + var forwarding = witness.OnNextAsync(One, CancellationToken.None); + await Assert.That(forwarding.IsCompleted).IsFalse(); + await witness.OnNextAsync(Two, CancellationToken.None); + await Assert.That(values).IsEmpty(); + release.SetResult(); + await forwarding; + await witness.OnNextAsync(Three, CancellationToken.None); - await Assert.That(values).Contains(One); + await Assert.That(values).IsCollectionEqualTo([One, Three]); } /// Verifies that the async-accumulator ScanWithInitial overload forwards upstream non-terminal errors downstream. @@ -595,6 +548,33 @@ public async Task WhenFusedOperatorSubscribedWithAlreadyCancelledToken_ThenSinkC } } + /// Verifies that stale distinct delays finish without forwarding their superseded values. + /// A task representing the asynchronous test. + [Test] + public async Task WhenThrottleDistinctDelayIsSuperseded_ThenOnlyCurrentDelayEmits() + { + ManualTimeProvider time = new(); + List values = []; + CallbackWitnessAsync downstream = new((value, _) => + { + values.Add(value); + return default; + }); + await using SignalAsyncExtensions.ThrottleDistinctSignal.ThrottleDistinctWitness observer = new( + downstream, + ShortDebounceWindow, + time, + CancellationToken.None); + var first = observer.StartDelayAsync(One, CancellationToken.None); + var second = observer.StartDelayAsync(Two, CancellationToken.None); + await time.FireNextAsync(); + await first; + await Assert.That(values).IsEmpty(); + await time.FireNextAsync(); + await second; + await Assert.That(values).IsCollectionEqualTo([Two]); + } + /// Verifies that an unhandled exception thrown by the downstream observer inside /// ThrottleDistinct's delayed-emit task is routed to /// . @@ -603,16 +583,18 @@ public async Task WhenFusedOperatorSubscribedWithAlreadyCancelledToken_ThenSinkC public async Task WhenThrottleDistinctDownstreamThrowsInDelay_ThenRoutedToUnhandled() { using UnhandledExceptionCapture unhandled = new(); + ManualTimeProvider time = new(); var signal = Signal.Create(); ThrowingAsyncWitness throwingObserver = new(new InvalidOperationException(DownstreamThrowsMessage)); await using var sub = await signal.Values - .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), time) .SubscribeAsync(throwingObserver, CancellationToken.None); await signal.OnNextAsync(One, CancellationToken.None); - var exception = await unhandled.WaitForAsync(DownstreamThrowsMessage, WaitTimeout); + await time.FireNextAsync(); + var exception = await unhandled.WaitForAsync(DownstreamThrowsMessage); await Assert.That(exception).IsNotNull(); await Assert.That(exception!.Message).IsEqualTo(DownstreamThrowsMessage); @@ -626,46 +608,48 @@ public async Task WhenThrottleDistinctDownstreamThrowsInDelay_ThenRoutedToUnhand public async Task WhenThrottleDownstreamThrowsInDelay_ThenRoutedToUnhandled() { using UnhandledExceptionCapture unhandled = new(); + ManualTimeProvider time = new(); var signal = Signal.Create(); ThrowingAsyncWitness throwingObserver = new(new InvalidOperationException(ThrottleDownstreamThrowsMessage)); await using var sub = await signal.Values - .Throttle(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds)) + .Throttle(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), time) .SubscribeAsync(throwingObserver, CancellationToken.None); await signal.OnNextAsync(One, CancellationToken.None); - var exception = await unhandled.WaitForAsync(ThrottleDownstreamThrowsMessage, WaitTimeout); + await time.FireNextAsync(); + var exception = await unhandled.WaitForAsync(ThrottleDownstreamThrowsMessage); await Assert.That(exception).IsNotNull(); await Assert.That(exception!.Message).IsEqualTo(ThrottleDownstreamThrowsMessage); } - /// 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. + /// Verifies that a newer pending value suppresses an older delay. /// A representing the asynchronous test operation. [Test] public async Task WhenDebounceUntilSecondEmissionSupersedesFirst_ThenStaleDelayDropsValue() { - var signal = Signal.Create(); + ManualTimeProvider time = new(); List values = []; - TaskCompletionSource emitted = new(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await signal.Values - .DebounceUntil(ShortDebounceWindow, static _ => false) - .SubscribeAsync((v, _) => + SignalAsyncExtensions.DebounceUntilSignal.DebounceUntilWitness observer = new( + new CallbackWitnessAsync((v, _) => { values.Add(v); - IgnoredResult.Of(emitted.TrySetResult()); return default; - }); - - await signal.OnNextAsync(One, CancellationToken.None); - await signal.OnNextAsync(Two, CancellationToken.None); - - await emitted.Task; + }), + ShortDebounceWindow, + static _ => false, + time, + CancellationToken.None); + await using var subscription = observer; + var first = observer.StartDelayAsync(One, CancellationToken.None); + var second = observer.StartDelayAsync(Two, CancellationToken.None); + await time.FireNextAsync(); + await first; + await Assert.That(values).IsEmpty(); + await time.FireNextAsync(); + await second; await Assert.That(values).IsCollectionEqualTo([Two]); } @@ -677,16 +661,18 @@ public async Task WhenDebounceUntilSecondEmissionSupersedesFirst_ThenStaleDelayD public async Task WhenDebounceUntilDownstreamThrowsInDelay_ThenRoutedToUnhandled() { using UnhandledExceptionCapture unhandled = new(); + ManualTimeProvider time = new(); var signal = Signal.Create(); ThrowingAsyncWitness throwingObserver = new(new InvalidOperationException(DebounceDownstreamThrowsMessage)); await using var sub = await signal.Values - .DebounceUntil(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), static _ => false) + .DebounceUntil(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), static _ => false, time) .SubscribeAsync(throwingObserver, CancellationToken.None); await signal.OnNextAsync(One, CancellationToken.None); - var exception = await unhandled.WaitForAsync(DebounceDownstreamThrowsMessage, WaitTimeout); + await time.FireNextAsync(); + var exception = await unhandled.WaitForAsync(DebounceDownstreamThrowsMessage); await Assert.That(exception).IsNotNull(); await Assert.That(exception!.Message).IsEqualTo(DebounceDownstreamThrowsMessage); @@ -726,7 +712,7 @@ public async Task WhenThrottleDistinctTryClaimEmissionSuperseded_ThenReturnsFals SignalAsyncExtensions.ThrottleDistinctSignal.ThrottleDistinctWitness observer = new( new NoOpAsyncWitness(), TimeSpan.FromHours(1), - TimeProvider.System, + new ManualTimeProvider(), CancellationToken.None); // Drive _id forward by two emissions; the first pending delay's id (1) is then stale. @@ -746,7 +732,7 @@ public async Task WhenThrottleDistinctTryClaimEmissionDuplicate_ThenReturnsFalse SignalAsyncExtensions.ThrottleDistinctSignal.ThrottleDistinctWitness observer = new( new NoOpAsyncWitness(), TimeSpan.FromHours(1), - TimeProvider.System, + new ManualTimeProvider(), CancellationToken.None); await observer.OnNextAsync(One, CancellationToken.None); @@ -757,8 +743,7 @@ public async Task WhenThrottleDistinctTryClaimEmissionDuplicate_ThenReturnsFalse // 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. + // Equal values remain suppressed across generation changes. var secondClaim = observer.TryClaimEmission(One, SecondEmissionId); await Assert.That(firstClaim).IsTrue(); @@ -774,7 +759,7 @@ public async Task WhenDebounceUntilIsCurrentEmission_ThenMatchesIdState() new NoOpAsyncWitness(), TimeSpan.FromHours(1), static _ => false, - TimeProvider.System, + new ManualTimeProvider(), CancellationToken.None); await observer.OnNextAsync(One, CancellationToken.None); @@ -819,6 +804,29 @@ public async Task WhenPartitionTryAttachSourceSubscriptionAndBranchAlive_ThenRet await Assert.That(attached).IsTrue(); } + /// Verifies that an upstream subscription arriving after both branches leave is disposed. + /// Whether one branch remains subscribed. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenPartitionSubscriptionArrives_ThenOwnershipFollowsBranchState(bool branchAlive) + { + await using var signal = Signal.Create(); + SignalAsyncExtensions.PartitionCoordinator coordinator = new(signal.Values, static _ => true); + await using var branch = branchAlive + ? await coordinator.TrueBranch.SubscribeAsync(static (_, _) => default) + : DisposableAsync.Empty; + StrongBox disposed = new(); + var incoming = DisposableAsync.Create(disposed, static state => + { + state.Value = true; + return default; + }); + await coordinator.AttachOrDisposeStaleSubscriptionAsync(incoming); + await Assert.That(disposed.Value).IsEqualTo(!branchAlive); + } + /// Yields values as a generic (neither array nor list) to drive the slow-path branch of ForEach. /// Values to yield. /// A lazily-evaluated enumerable. @@ -830,11 +838,7 @@ private static IEnumerable Enumerate(params int[] values) } } - /// 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. + /// Throws directly from OnNextAsync without handling the exception. /// The element type. /// The exception to throw on every emission. private sealed class ThrowingAsyncWitness(Exception error) : IObserverAsync diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.ObserveOn.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.ObserveOn.cs new file mode 100644 index 00000000..2c6f3cf7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.ObserveOn.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using AsyncObs = ReactiveUI.Primitives.Async.SignalAsync; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests optional context and scheduler selection. +public partial class ParityOperatorTests +{ + /// Tests that ObserveOnSafe with a null AsyncContext returns the source unchanged. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnSafeAsyncContext_WithNull_ThenReturnsSourceUnchanged() + { + var source = AsyncObs.Return(1); + + var observed = source.ObserveOnSafe((AsyncContext?)null); + + var result = await observed.FirstAsync(); + await Assert.That(result).IsEqualTo(1); + } + + /// Tests that ObserveOnSafe with a non-null AsyncContext applies ObserveOn. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnSafeAsyncContext_WithValue_ThenAppliesObserveOn() + { + var context = AsyncContext.Default; + + var result = await AsyncObs.Return(CanonicalAnswer) + .ObserveOnSafe(context) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(CanonicalAnswer); + } + + /// Tests that ObserveOnSafe with a null TaskScheduler returns the source unchanged. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnSafeTaskScheduler_WithNull_ThenReturnsSourceUnchanged() + { + var source = AsyncObs.Return(1); + + var observed = source.ObserveOnSafe((TaskScheduler?)null); + + var result = await observed.FirstAsync(); + await Assert.That(result).IsEqualTo(1); + } + + /// Tests that ObserveOnSafe with a non-null TaskScheduler applies ObserveOn. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnSafeTaskScheduler_WithValue_ThenAppliesObserveOn() + { + var result = await AsyncObs.Return(CanonicalAnswer) + .ObserveOnSafe(TaskScheduler.Default) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(CanonicalAnswer); + } + + /// Tests that ObserveOnIf with true condition applies ObserveOn with AsyncContext. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfAsyncContext_WithTrueCondition_ThenAppliesObserveOn() + { + var context = AsyncContext.Default; + + var result = await AsyncObs.Return(CanonicalAnswer) + .ObserveOnIf(true, context) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(CanonicalAnswer); + } + + /// Tests that ObserveOnIf with false condition returns the source unchanged for AsyncContext. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfAsyncContext_WithFalseCondition_ThenReturnsSourceUnchanged() + { + var context = AsyncContext.Default; + + var result = await AsyncObs.Return(CanonicalAnswer) + .ObserveOnIf(false, context) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(CanonicalAnswer); + } + + /// Tests that ObserveOnIf with true condition applies ObserveOn with TaskScheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfTaskScheduler_WithTrueCondition_ThenAppliesObserveOn() + { + var result = await AsyncObs.Return(CanonicalAnswer) + .ObserveOnIf(true, TaskScheduler.Default) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(CanonicalAnswer); + } + + /// Tests that ObserveOnIf with false condition returns the source unchanged for TaskScheduler. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenObserveOnIfTaskScheduler_WithFalseCondition_ThenReturnsSourceUnchanged() + { + var result = await AsyncObs.Return(CanonicalAnswer) + .ObserveOnIf(false, TaskScheduler.Default) + .FirstAsync(); + + await Assert.That(result).IsEqualTo(CanonicalAnswer); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.cs index 54e4ff6f..b86b0d98 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ParityOperatorTests.cs @@ -8,11 +8,8 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests for async parity helpers that mirror the synchronous helper surface in the repository. -public class ParityOperatorTests +public partial class ParityOperatorTests { - /// Seconds a test waits for a notification before giving up. - private const int WaitTimeoutSeconds = 5; - /// Sentinel value (42) used by tests. private const int CanonicalAnswer = 42; @@ -43,9 +40,6 @@ public class ParityOperatorTests /// Window used by the throttle and debounce timing tests. private static readonly TimeSpan ThrottleWindow = TimeSpan.FromMilliseconds(50); - /// Maximum time a test waits for an emission to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - /// Tests that WhereIsNotNull filters null values and narrows the result type. /// A representing the asynchronous test operation. [Test] @@ -397,8 +391,6 @@ public async Task WhenDropIfBusy_WithBusyAction_ThenDropsValues() var emitTask = signal.OnNextAsync(1, CancellationToken.None).AsTask(); // Emit values 2 and 3 while the action for value 1 is still running - these should be dropped - // We need a small yield to ensure value 1's handler has started - await Task.Yield(); await signal.OnNextAsync(DroppedValueA, CancellationToken.None); await signal.OnNextAsync(DroppedValueB, CancellationToken.None); @@ -520,110 +512,6 @@ public async Task WhenWaitUntil_ThenEmitsFirstMatchingValue() await Assert.That(result).IsCollectionEqualTo([FirstMatch]); } - /// Tests that ObserveOnSafe with a null AsyncContext returns the source unchanged. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnSafeAsyncContext_WithNull_ThenReturnsSourceUnchanged() - { - var source = AsyncObs.Return(1); - - var observed = source.ObserveOnSafe((AsyncContext?)null); - - var result = await observed.FirstAsync(); - await Assert.That(result).IsEqualTo(1); - } - - /// Tests that ObserveOnSafe with a non-null AsyncContext applies ObserveOn. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnSafeAsyncContext_WithValue_ThenAppliesObserveOn() - { - var context = AsyncContext.Default; - - var result = await AsyncObs.Return(CanonicalAnswer) - .ObserveOnSafe(context) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(CanonicalAnswer); - } - - /// Tests that ObserveOnSafe with a null TaskScheduler returns the source unchanged. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnSafeTaskScheduler_WithNull_ThenReturnsSourceUnchanged() - { - var source = AsyncObs.Return(1); - - var observed = source.ObserveOnSafe((TaskScheduler?)null); - - var result = await observed.FirstAsync(); - await Assert.That(result).IsEqualTo(1); - } - - /// Tests that ObserveOnSafe with a non-null TaskScheduler applies ObserveOn. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnSafeTaskScheduler_WithValue_ThenAppliesObserveOn() - { - var result = await AsyncObs.Return(CanonicalAnswer) - .ObserveOnSafe(TaskScheduler.Default) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(CanonicalAnswer); - } - - /// Tests that ObserveOnIf with true condition applies ObserveOn with AsyncContext. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnIfAsyncContext_WithTrueCondition_ThenAppliesObserveOn() - { - var context = AsyncContext.Default; - - var result = await AsyncObs.Return(CanonicalAnswer) - .ObserveOnIf(true, context) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(CanonicalAnswer); - } - - /// Tests that ObserveOnIf with false condition returns the source unchanged for AsyncContext. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnIfAsyncContext_WithFalseCondition_ThenReturnsSourceUnchanged() - { - var context = AsyncContext.Default; - - var result = await AsyncObs.Return(CanonicalAnswer) - .ObserveOnIf(false, context) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(CanonicalAnswer); - } - - /// Tests that ObserveOnIf with true condition applies ObserveOn with TaskScheduler. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnIfTaskScheduler_WithTrueCondition_ThenAppliesObserveOn() - { - var result = await AsyncObs.Return(CanonicalAnswer) - .ObserveOnIf(true, TaskScheduler.Default) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(CanonicalAnswer); - } - - /// Tests that ObserveOnIf with false condition returns the source unchanged for TaskScheduler. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenObserveOnIfTaskScheduler_WithFalseCondition_ThenReturnsSourceUnchanged() - { - var result = await AsyncObs.Return(CanonicalAnswer) - .ObserveOnIf(false, TaskScheduler.Default) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(CanonicalAnswer); - } - /// Tests that ReplayLastOnSubscribe replays the initial value and subsequent source values. /// A representing the asynchronous test operation. [Test] @@ -641,12 +529,13 @@ public async Task WhenReplayLastOnSubscribe_ThenReplaysInitialAndSourceValues() [Test] public async Task WhenThrottleDistinct_ThenEmitsDistinctThrottledValues() { + ManualTimeProvider time = new(); var signal = Signal.Create(); List results = []; TaskCompletionSource firstReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await signal.Values - .ThrottleDistinct(ThrottleWindow) + .ThrottleDistinct(ThrottleWindow, time) .SubscribeAsync( (value, _) => { @@ -656,19 +545,15 @@ public async Task WhenThrottleDistinct_ThenEmitsDistinctThrottledValues() }, null); - // Emit duplicate values quickly - DistinctUntilChanged collapses them await signal.OnNextAsync(1, CancellationToken.None); await signal.OnNextAsync(1, CancellationToken.None); - - // Wait for throttle to emit the first distinct value - var received = await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - WaitTimeout); + await Assert.That(results).IsEmpty(); + await time.FireNextAsync(); + await firstReceived.Task; await signal.OnCompletedAsync(Result.Success); - await Assert.That(received).IsTrue(); - await Assert.That(results[0]).IsEqualTo(1); + await Assert.That(results).IsCollectionEqualTo([1]); } /// Tests that the async ScanWithInitial overload emits the seed followed by accumulated values. @@ -721,31 +606,29 @@ public async Task WhenDebounceUntil_WithConditionTrue_ThenEmitsImmediately() public async Task WhenDebounceUntil_WithConditionFalse_ThenDelaysEmission() { const int UnreachableThreshold = 100; - + ManualTimeProvider time = new(); + TaskCompletionSource received = new(TaskCreationOptions.RunContinuationsAsynchronously); var signal = Signal.Create(); List results = []; await using var sub = await signal.Values - .DebounceUntil(ThrottleWindow, static v => v > UnreachableThreshold) + .DebounceUntil(ThrottleWindow, static v => v > UnreachableThreshold, time) .SubscribeAsync( (value, _) => { results.Add(value); + IgnoredResult.Of(received.TrySetResult()); return default; }, null); - // Value 1 does not satisfy condition, should be delayed by 50ms await signal.OnNextAsync(1, CancellationToken.None); - - // Wait for the delayed value to arrive - var received = await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count >= 1, - WaitTimeout); + await Assert.That(results).IsEmpty(); + await time.FireNextAsync(); + await received.Task; await signal.OnCompletedAsync(Result.Success); - await Assert.That(received).IsTrue(); await Assert.That(results[0]).IsEqualTo(1); } @@ -1082,9 +965,7 @@ public async Task WhenDropIfBusy_WithConcurrentEmission_ThenDroppedValueIsDiscar var emit1Task = source.EmitNext(1).AsTask(); // Wait until the action for value 1 has actually started. - await AsyncTestHelpers.WaitForConditionAsync( - () => Volatile.Read(ref actionStarted) >= 1, - WaitTimeout); + await Assert.That(Volatile.Read(ref actionStarted) >= 1).IsTrue(); // Emit value 2 while value 1 is still processing - it should be dropped. await source.EmitNext(DroppedValue); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj b/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj index 3632a9fc..5c5df252 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async.Tests.csproj @@ -12,10 +12,6 @@ - - - - diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ReplayLatestSignalAsyncStateObserverLeaseTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ReplayLatestSignalAsyncStateObserverLeaseTests.cs new file mode 100644 index 00000000..12b6f3b7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ReplayLatestSignalAsyncStateObserverLeaseTests.cs @@ -0,0 +1,68 @@ +// 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.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests replay observer removal during cancellation and teardown. +public sealed class ReplayLatestSignalAsyncStateObserverLeaseTests +{ + /// The gate has been disposed independently of its signal state. + private const int GateDisposed = 2; + + /// The whole signal state has been disposed. + private const int StateDisposed = 3; + + /// Verifies replay lease removal tolerates cancellation and prior state or gate disposal. + /// The state present when lease disposal starts. + /// A task representing the asynchronous test. + [Test] + [Arguments(0)] + [Arguments(1)] + [Arguments(GateDisposed)] + [Arguments(StateDisposed)] + public async Task WhenReplayLeaseDisposed_ThenRemovalFollowsState(int stateKind) + { + ReplayLatestSignalAsyncState state = new(default); + try + { + CallbackWitnessAsync observer = new(static (_, _) => default); + state.Observers = [observer]; + state.IsDisposed = stateKind == StateDisposed; + ReplayLatestSignalAsyncStateObserverLease lease = new(state, observer); + using AsyncSerialGate.Lease? held = stateKind is 1 or GateDisposed + ? await state.Gate.EnterForThreadAsync(-1, CancellationToken.None) + : null; + switch (stateKind) + { + case 1: + { + await state.DisposedCts.CancelAsync(); + break; + } + + case GateDisposed: + { + state.Gate.Dispose(); + break; + } + + case StateDisposed: + { + state.Dispose(); + break; + } + } + + await lease.DisposeAsync(); + await lease.DisposeAsync(); + await Assert.That(state.Observers.Contains(observer)).IsEqualTo(stateKind is 1 or GateDisposed or StateDisposed); + } + finally + { + state.Dispose(); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ResultAndInfrastructureTests.Multicast.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ResultAndInfrastructureTests.Multicast.cs index 08d0bffc..957f549f 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ResultAndInfrastructureTests.Multicast.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ResultAndInfrastructureTests.Multicast.cs @@ -13,18 +13,12 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Multicast / RefCount tests. public class ResultAndInfrastructureTests { - /// Seconds a test waits for a notification before giving up. - private const int WaitTimeoutSeconds = 5; - /// Number of values in the range sources used by the multicast tests. private const int SourceValueCount = 3; /// Value emitted by the single-value multicast source. private const int MulticastValue = 42; - /// Maximum time a test waits for a notification to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - /// Verifies concurrent observer exception constructors preserve messages and inner exceptions. /// A representing the asynchronous test operation. [Test] @@ -82,38 +76,24 @@ public async Task WhenObserverOnNextThrowsOperationCanceledSynchronously_ThenCal await Assert.That(observer.Cancelled).IsTrue(); } - /// Verifies concurrent cross-thread observer calls are reported through the unhandled exception handler. + /// Verifies that an overlapping call from a different thread identifier is rejected and reported. /// A representing the asynchronous test operation. [Test] public async Task WhenObserverCalledConcurrentlyFromDifferentThread_ThenUnhandledExceptionReported() { - const int ConcurrentValue = 2; + const int FirstThreadId = 1; + const int SecondThreadId = 2; using UnhandledExceptionCapture capture = new(); - BlockingObserver observer = new(); - var first = Task.Factory.StartNew( - static async state => - { - var blockingObserver = (BlockingObserver)state!; - await blockingObserver.OnNextAsync(1, CancellationToken.None); - }, - observer, - CancellationToken.None, - TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, - TaskScheduler.Default).Unwrap(); - try - { - await observer.Entered.Task.WaitAsync(WaitTimeout); - await observer.OnNextAsync(ConcurrentValue, CancellationToken.None); - } - finally - { - _ = observer.Release.TrySetResult(); - } - - await first.WaitAsync(WaitTimeout); + await using CallbackWitnessAsync observer = new(static (_, _) => default); + var entered = observer.TryEnterOnSomethingCall(FirstThreadId, CancellationToken.None, out var scope); + var concurrentEntered = observer.TryEnterOnSomethingCall(SecondThreadId, CancellationToken.None, out var rejectedScope); + scope.Dispose(); + rejectedScope.Dispose(); + _ = observer.ExitOnSomethingCall(); + await Assert.That(entered).IsTrue(); + await Assert.That(concurrentEntered).IsFalse(); var reported = await capture.WaitForAsync( - static exception => exception is ConcurrentWitnessCallsException, - WaitTimeout); + static exception => exception is ConcurrentWitnessCallsException); await Assert.That(reported).IsNotNull(); } @@ -326,7 +306,7 @@ public async Task WhenMulticastSubscribeAndConnect_ThenItemsFlowThroughSignal() return default; }); await using var conn = await connectable.ConnectAsync(CancellationToken.None); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).IsCollectionEqualTo([FirstValue, SecondValue]); } @@ -374,7 +354,7 @@ public async Task WhenRefCountSourceErrorResume_ThenForwardsToSubscriber() captured = ex; return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => captured is not null, WaitTimeout); + await Assert.That(captured is not null).IsTrue(); await Assert.That(items).Contains(1); await Assert.That(captured).IsNotNull(); await Assert.That(captured!.Message).IsEqualTo("refcount-error"); @@ -400,28 +380,4 @@ protected override ValueTask OnErrorResumeAsyncCore(Exception error, Cancellatio /// protected override ValueTask OnCompletedAsyncCore(Result result) => default; } - - /// Observer that blocks OnNext until explicitly released. - private sealed class BlockingObserver : WitnessAsync - { - /// Gets the signal set after the first OnNext call has entered. - public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Gets the signal that releases the blocked OnNext call. - public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// - protected override async ValueTask OnNextAsyncCore(int value, CancellationToken cancellationToken) - { - _ = Entered.TrySetResult(); - await Release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - /// - protected override ValueTask OnErrorResumeAsyncCore(Exception error, CancellationToken cancellationToken) => - default; - - /// - protected override ValueTask OnCompletedAsyncCore(Result result) => default; - } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.BehaviorAndReplay.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.BehaviorAndReplay.cs index 53b9faa1..884b395b 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.BehaviorAndReplay.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.BehaviorAndReplay.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Async.Tests; /// BehaviorSignal and ReplayLatest tests for . -[System.Diagnostics.DebuggerDisplay("SignalTests: WaitTimeout = {WaitTimeout}")] +[System.Diagnostics.DebuggerDisplay("SignalTests")] public partial class SignalTests { /// Tests behavior Signal with start value emits latest first to new subscriber. @@ -29,7 +29,7 @@ public async Task WhenBehaviorSignalWithStartValue_ThenNewSubscriberReceivesLate }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo(StartValue); @@ -59,7 +59,7 @@ public async Task WhenBehaviorSignalConcurrent_ThenNewSubscriberReceivesLatest() }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo(StartValue); @@ -88,7 +88,7 @@ public async Task WhenReplayLatestSignal_ThenLateSubscriberGetsLatestValue() }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo(LatestValue); @@ -120,7 +120,7 @@ public async Task WhenReplayLatestSignalConcurrent_ThenLateSubscriberGetsLatest( }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo(PushedValue); @@ -145,7 +145,7 @@ public async Task WhenBehaviorSignalStateless_ThenEmitsStartValueToNewSubscriber }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo("initial"); @@ -173,7 +173,7 @@ public async Task WhenReplayLatestStateless_ThenEmitsLatestToNewSubscriber() }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo(PushedValue); @@ -205,7 +205,7 @@ public async Task WhenConcurrentStatelessReplayLatest_ThenEmitsLatest() }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo(PushedValue); @@ -396,7 +396,7 @@ public async Task WhenConcurrentStatelessBehavior_ThenEmitsStartValue() }, null); - await firstReceived.Task.WaitAsync(WaitTimeout); + await firstReceived.Task; await Assert.That(items).Count().IsGreaterThanOrEqualTo(1); await Assert.That(items[0]).IsEqualTo(StartValue); @@ -658,7 +658,7 @@ public async Task WhenReplayLatestOnNextWithCustomToken_ThenForwardsValue() const int LinkedCtsValue = 11; await signal.OnNextAsync(LinkedCtsValue, cts.Token); - var received = await tcs.Task.WaitAsync(WaitTimeout); + var received = await tcs.Task; await Assert.That(received).IsEqualTo(LinkedCtsValue); } @@ -683,7 +683,7 @@ public async Task WhenReplayLatestOnErrorResumeWithCustomToken_ThenForwardsError using CancellationTokenSource cts = new(); await signal.OnErrorResumeAsync(expected, cts.Token); - var received = await tcs.Task.WaitAsync(WaitTimeout); + var received = await tcs.Task; await Assert.That(received).IsSameReferenceAs(expected); } @@ -706,7 +706,7 @@ public async Task WhenStatelessReplayLatestOnNextWithCustomToken_ThenForwardsVal const int LinkedCtsValue = 17; await signal.OnNextAsync(LinkedCtsValue, cts.Token); - var received = await tcs.Task.WaitAsync(WaitTimeout); + var received = await tcs.Task; await Assert.That(received).IsEqualTo(LinkedCtsValue); } @@ -731,7 +731,7 @@ public async Task WhenStatelessReplayLatestOnErrorResumeWithCustomToken_ThenForw using CancellationTokenSource cts = new(); await signal.OnErrorResumeAsync(expected, cts.Token); - var received = await tcs.Task.WaitAsync(WaitTimeout); + var received = await tcs.Task; await Assert.That(received).IsSameReferenceAs(expected); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.CreationOptions.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.CreationOptions.cs index 3b7ae94b..4aad4ae1 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.CreationOptions.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.CreationOptions.cs @@ -36,7 +36,7 @@ public async Task WhenCreateWithSerialStatefulOptions_ThenPublishesToSubscribers await signal.OnNextAsync(OptionsStartValue, CancellationToken.None); - var value = await received.Task.WaitAsync(WaitTimeout); + var value = await received.Task; await Assert.That(value).IsEqualTo(OptionsStartValue); } @@ -55,7 +55,7 @@ public async Task WhenCreateBehaviorWithSerialStatefulOptions_ThenReplaysStartVa return default; }); - var value = await received.Task.WaitAsync(WaitTimeout); + var value = await received.Task; await Assert.That(value).IsEqualTo(OptionsStartValue); } @@ -76,7 +76,7 @@ public async Task WhenCreateReplayLatestWithSerialStatefulOptions_ThenReplaysLat return default; }); - var value = await received.Task.WaitAsync(WaitTimeout); + var value = await received.Task; await Assert.That(value).IsEqualTo(OptionsLatestValue); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs index ac78959d..30c4d4ca 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.MappedAndConcurrent.cs @@ -40,7 +40,7 @@ public async Task WhenMapValues_ThenTransformsObservable() await mapped.OnNextAsync(FirstInput, CancellationToken.None); await mapped.OnNextAsync(SecondInput, CancellationToken.None); await mapped.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).IsCollectionEqualTo([FirstMapped, SecondMapped]); } @@ -545,7 +545,7 @@ public async Task WhenConcurrentSignalWithMultipleObservers_ThenAllReceiveOnComp return default; }); await signal.OnCompletedAsync(Result.Success); - await completed1.Task.WaitAsync(WaitTimeout); - await completed2.Task.WaitAsync(WaitTimeout); + await completed1.Task; + await completed2.Task; } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.cs index 0735a093..a5ced384 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalTests.cs @@ -9,12 +9,6 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests for SignalAsync factory, all Signal variants, and SignalExtensions. public partial class SignalTests { - /// Seconds a test waits for a notification before giving up. - private const int WaitTimeoutSeconds = 5; - - /// Maximum time a test waits for a signal notification to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - #if NET9_0_OR_GREATER /// Synchronization gate used by tests. private readonly Lock _gate = new(); @@ -54,7 +48,7 @@ public async Task WhenSerialSignalPushValues_ThenAllObserversReceiveInOrder() await signal.OnNextAsync(ThirdValue, CancellationToken.None); await signal.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).IsCollectionEqualTo([FirstValue, SecondValue, ThirdValue]); } @@ -94,7 +88,7 @@ public async Task WhenConcurrentSignalPushValues_ThenAllObserversReceive() await signal.OnNextAsync(SecondValue, CancellationToken.None); await signal.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).Count().IsEqualTo(ExpectedCount); } @@ -126,7 +120,7 @@ public async Task WhenSerialStatelessSignalPushValues_ThenObserversReceive() await signal.OnNextAsync("b", CancellationToken.None); await signal.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).IsCollectionEqualTo(["a", "b"]); } @@ -163,7 +157,7 @@ public async Task WhenConcurrentStatelessSignalPushValues_ThenObserversReceive() await signal.OnNextAsync(PushedValue, CancellationToken.None); await signal.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).IsCollectionEqualTo([PushedValue]); } @@ -187,7 +181,7 @@ public async Task WhenSignalOnErrorResume_ThenObserverReceivesError() }); await signal.OnErrorResumeAsync(new InvalidOperationException("test"), CancellationToken.None); - await errorReceived.Task.WaitAsync(WaitTimeout); + await errorReceived.Task; const int ExpectedErrorCount = 1; await Assert.That(errors).Count().IsEqualTo(ExpectedErrorCount); @@ -214,7 +208,7 @@ public async Task WhenSignalOnCompleted_ThenObserverReceivesCompletion() }); await signal.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); @@ -240,7 +234,7 @@ public async Task WhenSignalOnCompletedWithFailure_ThenObserverReceivesFailure() }); await signal.OnCompletedAsync(Result.Failure(new InvalidOperationException("fatal"))); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); @@ -290,7 +284,7 @@ public async Task WhenMultipleObservers_ThenAllReceiveValues() await signal.OnNextAsync(SecondValue, CancellationToken.None); await signal.OnCompletedAsync(Result.Success); - await Task.WhenAll(completed1.Task, completed2.Task).WaitAsync(WaitTimeout); + await Task.WhenAll(completed1.Task, completed2.Task); await Assert.That(items1).IsCollectionEqualTo([FirstValue, SecondValue]); await Assert.That(items2).IsCollectionEqualTo([FirstValue, SecondValue]); @@ -326,7 +320,7 @@ public async Task WhenAsObserverAsync_ThenForwardsToSignal() await observer.OnNextAsync(SecondValue, CancellationToken.None); await observer.OnCompletedAsync(Result.Success); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).IsCollectionEqualTo([FirstValue, SecondValue]); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/StatelessReplayLatestSignalAsyncStateObserverLeaseTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/StatelessReplayLatestSignalAsyncStateObserverLeaseTests.cs new file mode 100644 index 00000000..df9bbdfc --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/StatelessReplayLatestSignalAsyncStateObserverLeaseTests.cs @@ -0,0 +1,83 @@ +// 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.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests stateless replay observer removal and initial-value restoration. +public sealed class StatelessReplayLatestSignalAsyncStateObserverLeaseTests +{ + /// The gate has been disposed independently of its signal state. + private const int GateDisposed = 2; + + /// The whole signal state has been disposed. + private const int StateDisposed = 3; + + /// A second observer remains after removal. + private const int AnotherObserver = 4; + + /// Verifies the final stateless replay lease restores its initial value. + /// The state present when lease disposal starts. + /// A task representing the asynchronous test. + [Test] + [Arguments(0)] + [Arguments(1)] + [Arguments(GateDisposed)] + [Arguments(StateDisposed)] + [Arguments(AnotherObserver)] + public async Task WhenStatelessReplayLeaseDisposed_ThenLastRemovalResetsValue(int stateKind) + { + const int InitialValue = 1; + const int PublishedValue = 2; + StatelessReplayLatestSignalAsyncState state = new(new(InitialValue)); + try + { + CallbackWitnessAsync observer = new(static (_, _) => default); + state.Observers = stateKind == AnotherObserver + ? [observer, new CallbackWitnessAsync(static (_, _) => default)] + : [observer]; + state.Value = new(PublishedValue); + state.IsDisposed = stateKind == StateDisposed; + StatelessReplayLatestSignalAsyncStateObserverLease lease = new(state, observer); + using AsyncSerialGate.Lease? held = stateKind is 1 or GateDisposed + ? await state.Gate.EnterForThreadAsync(-1, CancellationToken.None) + : null; + switch (stateKind) + { + case 1: + { + await state.DisposedCts.CancelAsync(); + break; + } + + case GateDisposed: + { + state.Gate.Dispose(); + break; + } + + case StateDisposed: + { + state.Dispose(); + break; + } + } + + await lease.DisposeAsync(); + await lease.DisposeAsync(); + await Assert.That(state.Observers.Contains(observer)).IsEqualTo(stateKind is 1 or GateDisposed or StateDisposed); + if (stateKind == StateDisposed) + { + return; + } + + await Assert.That(state.Value.Value).IsEqualTo(stateKind == 0 ? InitialValue : PublishedValue); + } + finally + { + state.Dispose(); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.HighArity.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.HighArity.cs index 2f7dd195..8792e9b0 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.HighArity.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.HighArity.cs @@ -44,7 +44,7 @@ public async Task WhenSyncLatestTwelveSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityTwelve); @@ -86,7 +86,7 @@ public async Task WhenSyncLatestThirteenSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityThirteen); @@ -130,7 +130,7 @@ public async Task WhenSyncLatestFourteenSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityFourteen); @@ -175,7 +175,7 @@ public async Task WhenSyncLatestFifteenSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityFifteen); @@ -221,7 +221,7 @@ public async Task WhenSyncLatestSixteenSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(AritySixteen); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.LowArity.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.LowArity.cs index 74e45ac3..7c9891b3 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.LowArity.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.LowArity.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// Tests for the arity-3 through arity-7 SyncLatest spelling of the combine-latest operator. The -/// CombineLatest spelling of these overloads is exercised elsewhere; these cover the SyncLatest -/// alias methods, which forward to the same signal. Each source contributes a value of one, so the projected -/// result equals the arity once every source has produced a value. -/// +/// Tests SyncLatest with three through seven sources, each contributing one to the projected sum. public partial class SyncLatestOperatorTests { /// Number of sources combined by the arity-3 overload. @@ -55,7 +50,7 @@ public async Task WhenSyncLatestThreeSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityThree); @@ -88,7 +83,7 @@ public async Task WhenSyncLatestFourSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityFour); @@ -122,7 +117,7 @@ public async Task WhenSyncLatestFiveSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityFive); @@ -157,7 +152,7 @@ public async Task WhenSyncLatestSixSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(AritySix); @@ -193,7 +188,7 @@ public async Task WhenSyncLatestSevenSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(AritySeven); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs index 87f6b7bb..bdcada2b 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs @@ -13,9 +13,6 @@ namespace ReactiveUI.Primitives.Async.Tests; /// public partial class SyncLatestOperatorTests { - /// Seconds a test waits for a combined emission before giving up. - private const int WaitTimeoutSeconds = 5; - /// Index of source 3 in the per-test source list. private const int SourceIndex2 = 2; @@ -88,9 +85,6 @@ public partial class SyncLatestOperatorTests /// Value emitted by the second source in the enumerable tests. private const int SecondValue = 2; - /// Maximum time a test waits for a combined emission to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - /// Verifies the enumerable SyncLatest emits a snapshot of the latest value of every source /// once all of them have produced one. /// A representing the asynchronous test operation. @@ -116,7 +110,7 @@ public async Task WhenSyncLatestOverEnumerable_ThenEmitsSnapshotOfLatestValues() await first.OnNextAsync(1, CancellationToken.None); await second.OnNextAsync(SecondValue, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => snapshots.Count >= 1, WaitTimeout); + await Assert.That(snapshots.Count >= 1).IsTrue(); await Assert.That(snapshots).Count().IsGreaterThanOrEqualTo(1); await Assert.That(snapshots[0]).IsCollectionEqualTo([1, SecondValue]); @@ -143,7 +137,7 @@ public async Task WhenSyncLatestOverEnumerableWithSelector_ThenProjectsSnapshot( await first.OnNextAsync(1, CancellationToken.None); await second.OnNextAsync(SecondValue, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(1 + SecondValue); @@ -180,7 +174,7 @@ public async Task WhenSyncLatestEightSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityEight); @@ -218,7 +212,7 @@ public async Task WhenSyncLatestNineSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityNine); @@ -257,7 +251,7 @@ public async Task WhenSyncLatestTenSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityTen); @@ -297,7 +291,7 @@ public async Task WhenSyncLatestElevenSources_ThenCombinesAll() await signals[index].OnNextAsync(1, CancellationToken.None); } - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, WaitTimeout); + await Assert.That(results.Count >= 1).IsTrue(); await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); await Assert.That(results[0]).IsEqualTo(ArityEleven); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs index 3763de44..ea9d05d7 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.CompletionDelegate.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Async.Tests; /// TakeUntil operator tests — CompletionSignalDelegate overload and option behavior. -[System.Diagnostics.DebuggerDisplay("TakeUntilOperatorTests: WaitTimeout = {WaitTimeout}")] +[System.Diagnostics.DebuggerDisplay("TakeUntilOperatorTests")] public partial class TakeUntilOperatorTests { /// String literal "subscribe failed" used by multiple tests. @@ -737,7 +737,7 @@ public async Task WhenTakeUntilCompletionDelegateNotifiesTwice_ThenSecondNotific /// An that records how many times it has been disposed. private sealed class DisposeCountingAsyncDisposable : IAsyncDisposable { - /// Signals the first disposal so a test can await it instead of polling the count. + /// Signals the first disposal. private readonly TaskCompletionSource _disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); /// The number of times has been called. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.DisposalAndErrors.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.DisposalAndErrors.cs index 96df1c0f..02dc7b5c 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.DisposalAndErrors.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.DisposalAndErrors.cs @@ -14,15 +14,6 @@ public partial class TakeUntilOperatorTests /// Message thrown by an observer from its completion callback. private const string ObserverCompletionThrowsMessage = "observer completion throws"; - /// Time allowed for a stop signal to propagate before the next emission. - private static readonly TimeSpan PropagationDelay = TimeSpan.FromMilliseconds(100); - - /// Time allowed for a post-stop emission to be observed, had it not been suppressed. - private static readonly TimeSpan SuppressedEmissionDelay = TimeSpan.FromMilliseconds(50); - - /// Time the swallowed-exception tests idle to prove nothing crashes the test host. - private static readonly TimeSpan SwallowedExceptionWindow = TimeSpan.FromMilliseconds(200); - /// Tests TakeUntil(predicate) DisposeAsyncCore when subscription is not null. /// A representing the asynchronous test operation. [Test] @@ -57,17 +48,16 @@ public async Task WhenCancellationStopSignalCanceled_ThenCompletionForwarded() { using CancellationTokenSource cts = new(); var source = Signal.Create(); - Result? completionResult = null; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await source.Values.TakeUntil(cts.Token).SubscribeAsync(static (_, _) => default, null, result => { - completionResult = result; + IgnoredResult.Of(completed.TrySetResult(result)); return default; }); await source.OnNextAsync(1, CancellationToken.None); await cts.CancelAsync(); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult is not null, WaitTimeout); - await Assert.That(completionResult).IsNotNull(); - await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); + var completion = await completed.Task; + await Assert.That(completion.IsSuccess).IsTrue(); } /// @@ -80,21 +70,19 @@ public async Task WhenTakeUntilCompletionDelegateDisposableThrows_ThenCaughtGrac { var source = Signal.Create(); Result? completionResult = null; + Action? notify = null; await using var sub = await source.Values.TakeUntil(stop => { - _ = Task.Run(async () => - { - await Task.Yield(); - stop(Result.Success); - }); + notify = stop; return DisposableAsync.Create(static () => throw new InvalidOperationException("dispose fail")); }).SubscribeAsync(static (_, _) => default, null, result => { completionResult = result; return default; }); + notify!(Result.Success); await source.OnNextAsync(1, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult is not null, WaitTimeout); + await Assert.That(completionResult is not null).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); } @@ -117,9 +105,7 @@ public async Task WhenTakeUntilObservable_ThenStopsOnSignal() await source.OnNextAsync(1, CancellationToken.None); await source.OnNextAsync(SecondItem, CancellationToken.None); await stopper.OnNextAsync("stop", CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(static () => true, PropagationDelay); await source.OnNextAsync(ThirdItem, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(static () => true, SuppressedEmissionDelay); await Assert.That(items).Contains(1); await Assert.That(items).DoesNotContain(ThirdItem); } @@ -148,11 +134,9 @@ public async Task WhenTaskStopSignal_ThenStopsOnTaskCompletion() await source.OnNextAsync(1, CancellationToken.None); tcs.SetResult(); - // Wait for the sequence to have actually stopped rather than for a delay to elapse, so a slow - // machine cannot let the second value through before the stop has been relayed. - _ = await stopped.Task.WaitAsync(WaitTimeout); + // Completion must be delivered before the next source value. + _ = await stopped.Task; await source.OnNextAsync(SecondItem, CancellationToken.None); - await AsyncTestHelpers.WaitForConditionAsync(static () => true, SuppressedEmissionDelay); await Assert.That(items).Contains(1); await Assert.That(items).DoesNotContain(SecondItem); } @@ -174,7 +158,6 @@ public async Task WhenCancellationStopSignal_ThenStopsOnCancellation() null); await source.OnNextAsync(1, CancellationToken.None); await cts.CancelAsync(); - await AsyncTestHelpers.WaitForConditionAsync(static () => true, PropagationDelay); await Assert.That(items).Contains(1); } @@ -226,10 +209,9 @@ public async Task WhenTakeUntilDelegateOtherFailsAndOptionFalse_ThenErrorResumeF var source = Signal.Create(); List errors = []; await using var sub = await source.Values.TakeUntil( - notifyStop => + static notifyStop => { - // Fire stop with failure after a brief moment - _ = Task.Run(() => notifyStop(Result.Failure(new InvalidOperationException("delegate fail")))); + notifyStop(Result.Failure(new InvalidOperationException("delegate fail"))); return DisposableAsync.Empty; }, new TakeUntilOptions { SourceFailsWhenOtherFails = false }).SubscribeAsync( @@ -244,7 +226,7 @@ public async Task WhenTakeUntilDelegateOtherFailsAndOptionFalse_ThenErrorResumeF return default; }, static _ => default); - await AsyncTestHelpers.WaitForConditionAsync(() => errors.Count >= 1, WaitTimeout); + await Assert.That(errors.Count >= 1).IsTrue(); await Assert.That(errors).Count().IsGreaterThanOrEqualTo(1); } @@ -256,9 +238,9 @@ public async Task WhenTakeUntilDelegateOtherFailsAndOptionTrue_ThenCompletesWith var source = Signal.Create(); Result? completionResult = null; await using var sub = await source.Values.TakeUntil( - notifyStop => + static notifyStop => { - _ = Task.Run(() => notifyStop(Result.Failure(new InvalidOperationException("delegate fail")))); + notifyStop(Result.Failure(new InvalidOperationException("delegate fail"))); return DisposableAsync.Empty; }, new TakeUntilOptions { SourceFailsWhenOtherFails = true }).SubscribeAsync(static (_, _) => default, null, result => @@ -266,7 +248,7 @@ public async Task WhenTakeUntilDelegateOtherFailsAndOptionTrue_ThenCompletesWith completionResult = result; return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, WaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); } @@ -291,7 +273,7 @@ public async Task WhenTaskStopSignalAwaitStopThenCompleteFailsOptionFalse_ThenEr return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => errors.Count >= 1, WaitTimeout); + await Assert.That(errors.Count >= 1).IsTrue(); await Assert.That(errors).Count().IsGreaterThanOrEqualTo(1); } @@ -312,7 +294,7 @@ public async Task WhenTaskStopSignalAwaitStopThenCompleteFailsOptionTrue_ThenCom completionResult = result; return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, WaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); } @@ -348,7 +330,7 @@ public async Task WhenTaskStopSignalSourceEmitsErrorResume_ThenErrorIsForwarded( completionResult = result; return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, WaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(errors).Count().IsGreaterThanOrEqualTo(1); await Assert.That(completionResult).IsNotNull(); } @@ -397,7 +379,7 @@ public async Task WhenTakeUntilDelegateSignalFailsAndOptionFalse_ThenSendsErrorR // Fire the stop signal with a failure storedNotifyStop!(Result.Failure(new InvalidOperationException("signal error"))); - await AsyncTestHelpers.WaitForConditionAsync(() => errors.Count >= 1, WaitTimeout); + await Assert.That(errors.Count >= 1).IsTrue(); await Assert.That(errors).Count().IsGreaterThanOrEqualTo(1); } @@ -427,7 +409,7 @@ public async Task WhenTakeUntilDelegateSignalFailsAndOptionTrue_ThenCompletesWit return default; }); storedNotifyStop!(Result.Failure(new InvalidOperationException("signal error"))); - await AsyncTestHelpers.WaitForConditionAsync(() => completionResult.HasValue, WaitTimeout); + await Assert.That(completionResult.HasValue).IsTrue(); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsFailure).IsTrue(); } @@ -456,9 +438,6 @@ public async Task WhenTakeUntilDelegateForwardingThrows_ThenOuterCatchSwallows() // Fire the stop signal with success; OnCompletedAsync will throw because observer throws storedNotifyStop!(Result.Success); - - // The outer catch block should swallow the exception; no crash - await AsyncTestHelpers.WaitForConditionAsync(static () => true, SwallowedExceptionWindow); await sub.DisposeAsync(); } @@ -476,9 +455,6 @@ public async Task WhenTaskStopSignalForwardingThrows_ThenOuterCatchSwallows() // Complete the task; OnCompletedAsync will throw because observer throws tcs.SetResult(); - - // The outer catch block should swallow the exception; no crash - await AsyncTestHelpers.WaitForConditionAsync(static () => true, SwallowedExceptionWindow); await sub.DisposeAsync(); } @@ -499,9 +475,6 @@ public async Task WhenCancellationStopSignalForwardingThrows_ThenOuterCatchSwall // Cancel the token; CompleteFromCancellation will call OnCompletedAsync which will throw await cts.CancelAsync(); - - // The outer catch block should swallow the exception; no crash - await AsyncTestHelpers.WaitForConditionAsync(static () => true, SwallowedExceptionWindow); await sub.DisposeAsync(); } @@ -529,9 +502,6 @@ public async Task WhenTakeUntilDelegateErrorResumeThrows_ThenOuterCatchSwallows( // Signal a failure; SourceFailsWhenOtherFails=false so OnErrorResumeAsync is called, which throws storedNotifyStop!(Result.Failure(new InvalidOperationException("stop error"))); - - // The outer catch block should swallow the exception - await AsyncTestHelpers.WaitForConditionAsync(static () => true, SwallowedExceptionWindow); await sub.DisposeAsync(); } @@ -554,9 +524,6 @@ public async Task WhenTaskStopSignalErrorResumeThrows_ThenOuterCatchSwallows() // Fault the task; SourceFailsWhenOtherFails=false so OnErrorResumeAsync is called, which throws tcs.SetException(new InvalidOperationException("task error")); - - // The outer catch block should swallow the exception - await AsyncTestHelpers.WaitForConditionAsync(static () => true, SwallowedExceptionWindow); await sub.DisposeAsync(); } @@ -611,7 +578,7 @@ public async Task WhenCancellationStopSignal_ThenCompletesOnCancel() }); await source.EmitNext(1); await cts.CancelAsync(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).Contains(1); } @@ -638,7 +605,7 @@ public async Task WhenTaskStopSignalCompletes_ThenSourceCompletes() }); await source.EmitNext(1); tcs.SetResult(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(items).Contains(1); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.cs index 74682372..46c151f6 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TakeUntilOperatorTests.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// Deep coverage tests for all TakeUntil operator overloads: -/// TakeUntil(observable), TakeUntil(Task), TakeUntil(CancellationToken), -/// TakeUntil(predicate), TakeUntil(asyncPredicate), TakeUntil(CompletionSignalDelegate). -/// +/// Tests TakeUntil termination through signals, tasks, cancellation, predicates and callbacks. public partial class TakeUntilOperatorTests { /// String literal "warning" used by multiple tests. @@ -32,9 +28,6 @@ public partial class TakeUntilOperatorTests /// A predicate threshold no element of the test sources ever reaches. private const int UnreachableThreshold = 10; - - /// Maximum time a test waits for a completion signal to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); #if NET9_0_OR_GREATER /// Synchronization gate used by tests. @@ -228,7 +221,7 @@ public async Task WhenTaskStopSignalFailsAndOptionTrue_ThenCompletesWithFailure( }); await source.OnNextAsync(1, CancellationToken.None); tcs.SetException(new InvalidOperationException("task failed")); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsFailure).IsTrue(); } @@ -238,20 +231,17 @@ public async Task WhenTaskStopSignalFailsAndOptionTrue_ThenCompletesWithFailure( public async Task WhenTaskStopSignalFailsAndOptionFalse_ThenSendsErrorResume() { TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource errorReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); var source = Signal.Create(); - List errors = []; await using var sub = await source.Values.TakeUntil(tcs.Task).SubscribeAsync(static (_, _) => default, (ex, _) => { - errors.Add(ex); + IgnoredResult.Of(errorReceived.TrySetResult(ex)); return default; }); await source.OnNextAsync(1, CancellationToken.None); - tcs.SetException(new InvalidOperationException("task failed")); - - // Wait for the error to be relayed rather than assuming the task's continuation ran inline. - var resumed = await AsyncTestHelpers.WaitForConditionAsync(() => errors.Count == 1, WaitTimeout); - await Assert.That(resumed).IsTrue(); - await Assert.That(errors).Count().IsEqualTo(1); + InvalidOperationException expected = new("task failed"); + tcs.SetException(expected); + await Assert.That(await errorReceived.Task).IsSameReferenceAs(expected); } /// Tests that an already-completed task completes the sequence immediately. @@ -326,7 +316,7 @@ public async Task WhenTakeUntilAlreadyCanceledToken_ThenCompletesImmediately() _ = completed.TrySetResult(result); return default; }); - var completionResult = await completed.Task.WaitAsync(WaitTimeout); + var completionResult = await completed.Task; await Assert.That(completionResult.IsSuccess).IsTrue(); } @@ -600,7 +590,7 @@ public async Task WhenTakeUntilOtherWithCancellationToken_ThenCompletesOnCancell return default; }); await cts.CancelAsync(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; } /// Verifies the two-argument TakeUntil(task, cancellationToken) overload. @@ -621,7 +611,7 @@ public async Task WhenTaskStopSignalWithCancellationToken_ThenCompletesOnCancell return default; }); await cts.CancelAsync(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; } /// Verifies the predicate overload with a cancellable token reaches the CT-linked branch. @@ -641,7 +631,7 @@ public async Task WhenPredicateStopSignalWithCancellationToken_ThenCompletesOnCa return default; }); await cts.CancelAsync(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; } /// Verifies the async-predicate overload with a cancellable token reaches the CT-linked branch. @@ -661,6 +651,6 @@ public async Task WhenTakeUntilAsyncPredicateWithCancellationToken_ThenCompletes return default; }); await cts.CancelAsync(); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TaskSignalSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TaskSignalSubscriptionTests.cs index 54013540..087579e1 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TaskSignalSubscriptionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TaskSignalSubscriptionTests.cs @@ -9,14 +9,10 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests for lifecycle and disposal behavior. public sealed class TaskSignalSubscriptionTests { - /// Maximum time a reentrant dispose may take before it is treated as a deadlock. - private static readonly TimeSpan ReentrantDisposeTimeout = TimeSpan.FromSeconds(5); - - /// Verifies a reentrant dispose issued from within the job's own async flow does not deadlock, - /// even after the notification continuation has hopped to a different thread. - /// A task that completes when the subscription disposes; faults on timeout if a deadlock occurs. + /// Verifies that a resumed notification can dispose its own task subscription. + /// A task representing the asynchronous test. [Test] - public async Task WhenDisposedReentrantlyAfterThreadHop_ThenDoesNotDeadlock() + public async Task WhenDisposedReentrantlyAfterNotificationResumes_ThenCompletes() { TaskSignalSubscription? subscription = null; TaskCompletionSource subscriptionReady = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -25,7 +21,6 @@ public async Task WhenDisposedReentrantlyAfterThreadHop_ThenDoesNotDeadlock() ReentrantDisposingObserver observer = new(async () => { await subscriptionReady.Task.ConfigureAwait(false); - await Task.Yield(); await subscription!.DisposeAsync().ConfigureAwait(false); disposed.SetResult(); }); @@ -35,7 +30,7 @@ static async (obs, ct) => await obs.OnNextAsync(1, ct).ConfigureAwait(false), observer); subscriptionReady.SetResult(); - await disposed.Task.WaitAsync(ReentrantDisposeTimeout); + await disposed.Task; } /// Observer that runs a supplied callback from its OnNext notification. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ElementSelection.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ElementSelection.cs new file mode 100644 index 00000000..55fcbe6a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ElementSelection.cs @@ -0,0 +1,507 @@ +// 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.Primitives.Async.Disposables; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests first, last and single-element terminal selection. +public partial class TerminalOperatorTests +{ + /// Tests FirstAsync returns first element. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstAsync_ThenReturnsFirstElement() + { + const int ExpectedFirst = 10; + var result = await SignalAsync.Range(ExpectedFirst, ShortSourceValueCount).FirstAsync(); + await Assert.That(result).IsEqualTo(ExpectedFirst); + } + + /// Tests FirstAsync with predicate returns first match. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstAsyncWithPredicate_ThenReturnsFirstMatch() + { + const int ExpectedFirstMatch = 4; + var result = await SignalAsync.Range(1, SourceValueCount).FirstAsync(static x => x > MatchThreshold); + await Assert.That(result).IsEqualTo(ExpectedFirstMatch); + } + + /// Tests FirstAsync reports an empty source with the no-elements message. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstAsyncOnEmpty_ThenThrowsInvalidOperation() + { + var ex = await Assert.That(static async () => await SignalAsync.Empty().FirstAsync()) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(NoElementsMessage); + } + + /// Tests FirstAsync with predicate when no elements match throws InvalidOperationException with matching message. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstAsyncWithPredicateNoMatch_ThenThrowsInvalidOperationWithMatchingMessage() + { + var ex = await Assert.That( + static async () => await SignalAsync.Range(1, SourceValueCount) + .FirstAsync(static x => x > UnmatchableThreshold)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(NoMatchingElementsMessage); + } + + /// Tests FirstAsync propagates error from OnErrorResumeAsync. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstAsyncSourceEmitsErrorResume_ThenThrowsSourceException() + { + InvalidOperationException expectedError = new(ResumeErrorMessage); + var source = SignalAsync.Create(async (observer, ct) => + { + await observer.OnErrorResumeAsync(expectedError, ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.FirstAsync()).ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); + } + + /// Tests FirstAsync propagates error when source completes with failure result. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstAsyncSourceCompletesWithFailure_ThenThrowsSourceException() + { + InvalidOperationException expectedError = new(SourceFailedMessage); + var source = SignalAsync.Create(async (observer, _) => + { + await observer.OnCompletedAsync(new(expectedError)); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.FirstAsync()).ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(SourceFailedMessage); + } + + /// Tests FirstOrDefault on empty returns default. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstOrDefaultOnEmpty_ThenReturnsDefault() + { + var result = await SignalAsync.Empty().FirstOrDefaultAsync(); + await Assert.That(result).IsEqualTo(0); + } + + /// Tests FirstOrDefault with predicate match returns first. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstOrDefaultWithMatch_ThenReturnsFirst() + { + const int ExpectedFirstMatch = 4; + var result = await SignalAsync.Range(1, SourceValueCount).Where(static x => x > MatchThreshold).FirstOrDefaultAsync(0); + await Assert.That(result).IsEqualTo(ExpectedFirstMatch); + } + + /// Tests FirstOrDefaultAsync with predicate returns first matching element. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstOrDefaultAsyncWithPredicate_ThenReturnsFirstMatch() + { + const int ExpectedFirstMatch = 4; + var result = await SignalAsync.Range(1, SourceValueCount).FirstOrDefaultAsync(static x => x > MatchThreshold, -1); + await Assert.That(result).IsEqualTo(ExpectedFirstMatch); + } + + /// Tests FirstOrDefaultAsync with predicate and no match returns specified default value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstOrDefaultAsyncWithPredicateNoMatch_ThenReturnsDefaultValue() + { + var result = await SignalAsync.Range(1, SourceValueCount).FirstOrDefaultAsync(static x => x > UnmatchableThreshold, -1); + await Assert.That(result).IsEqualTo(-1); + } + + /// Tests FirstOrDefaultAsync with predicate propagates error from OnErrorResumeAsync. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstOrDefaultAsyncWithPredicateSourceEmitsErrorResume_ThenThrows() + { + InvalidOperationException expectedError = new(ResumeErrorMessage); + var source = SignalAsync.Create(async (observer, ct) => + { + await observer.OnErrorResumeAsync(expectedError, ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.FirstOrDefaultAsync(static x => x > 0, -1)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); + } + + /// Tests FirstOrDefaultAsync propagates error from OnErrorResumeAsync. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenFirstOrDefaultAsyncSourceEmitsErrorResume_ThenThrows() + { + InvalidOperationException expectedError = new(ResumeErrorMessage); + var source = SignalAsync.Create(async (observer, ct) => + { + await observer.OnErrorResumeAsync(expectedError, ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.FirstOrDefaultAsync()) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); + } + + /// Tests LastAsync returns last element. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastAsync_ThenReturnsLastElement() + { + const int ExpectedLast = 5; + var result = await SignalAsync.Range(1, SourceValueCount).LastAsync(); + await Assert.That(result).IsEqualTo(ExpectedLast); + } + + /// Tests LastAsync with predicate returns last match. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastAsyncWithPredicate_ThenReturnsLastMatch() + { + const int ExpectedLastMatch = 3; + var result = await SignalAsync.Range(1, SourceValueCount).LastAsync(static x => x < UpperMatchBound); + await Assert.That(result).IsEqualTo(ExpectedLastMatch); + } + + /// Tests LastAsync on empty throws. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenLastAsyncOnEmpty_ThenThrowsInvalidOperation() => await Assert + .That(static async () => await SignalAsync.Empty().LastAsync()) + .ThrowsExactly(); + + /// Tests LastAsync with predicate and no match throws with matching-elements message. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastAsyncWithPredicateNoMatch_ThenThrowsWithMatchingMessage() + { + var ex = await Assert.That( + static async () => await SignalAsync.Range(1, SourceValueCount) + .LastAsync(static x => x > UnmatchableThreshold)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(NoMatchingElementsMessage); + } + + /// Tests LastAsync on empty throws with no-elements message. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastAsyncOnEmpty_ThenThrowsWithNoElementsMessage() + { + var ex = await Assert.That(static async () => await SignalAsync.Empty().LastAsync()) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(NoElementsMessage); + } + + /// Tests LastAsync propagates error from OnErrorResumeAsync. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastAsyncSourceEmitsErrorResume_ThenThrowsSourceException() + { + InvalidOperationException expectedError = new(ResumeErrorMessage); + var source = SignalAsync.Create(async (observer, ct) => + { + await observer.OnNextAsync(1, ct); + await observer.OnErrorResumeAsync(expectedError, ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.LastAsync()).ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); + } + + /// Tests LastAsync propagates error when source completes with failure result. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastAsyncSourceCompletesWithFailure_ThenThrowsSourceException() + { + InvalidOperationException expectedError = new(SourceFailedMessage); + var source = SignalAsync.Create(async (observer, _) => + { + await observer.OnCompletedAsync(new(expectedError)); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.LastAsync()).ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(SourceFailedMessage); + } + + /// Tests LastOrDefault on empty returns default. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastOrDefaultOnEmpty_ThenReturnsDefault() + { + var result = await SignalAsync.Empty().LastOrDefaultAsync(); + await Assert.That(result).IsEqualTo(0); + } + + /// Tests LastOrDefaultAsync with predicate returns the last matching element. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastOrDefaultAsyncWithPredicate_ThenReturnsLastMatch() + { + const int ExpectedLastMatch = 3; + var result = await SignalAsync.Range(1, SourceValueCount).LastOrDefaultAsync(static x => x < UpperMatchBound, -1); + await Assert.That(result).IsEqualTo(ExpectedLastMatch); + } + + /// Tests LastOrDefaultAsync with predicate returns default when no elements match. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastOrDefaultAsyncWithPredicateNoMatch_ThenReturnsDefaultValue() + { + var result = await SignalAsync.Range(1, SourceValueCount).LastOrDefaultAsync(static x => x > UnmatchableThreshold, -1); + await Assert.That(result).IsEqualTo(-1); + } + + /// Tests LastOrDefaultAsync with predicate on empty returns default value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastOrDefaultAsyncWithPredicateOnEmpty_ThenReturnsDefaultValue() + { + const int DefaultValue = 42; + var result = await SignalAsync.Empty().LastOrDefaultAsync(static _ => true, DefaultValue); + await Assert.That(result).IsEqualTo(DefaultValue); + } + + /// Tests LastOrDefaultAsync with custom default value on empty returns that default. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastOrDefaultAsyncWithDefaultValueOnEmpty_ThenReturnsCustomDefault() + { + const int CustomDefault = 99; + var result = await SignalAsync.Empty().LastOrDefaultAsync(CustomDefault); + await Assert.That(result).IsEqualTo(CustomDefault); + } + + /// Tests LastOrDefaultAsync propagates error from OnErrorResumeAsync. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastOrDefaultAsyncSourceEmitsErrorResume_ThenThrows() + { + InvalidOperationException expectedError = new(ResumeErrorMessage); + var source = SignalAsync.Create(async (observer, ct) => + { + await observer.OnNextAsync(1, ct); + await observer.OnErrorResumeAsync(expectedError, ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.LastOrDefaultAsync()) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); + } + + /// Tests LastOrDefaultAsync propagates error when source completes with failure. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLastOrDefaultAsyncSourceCompletesWithFailure_ThenThrows() + { + InvalidOperationException expectedError = new(SourceFailedMessage); + var source = SignalAsync.Create(async (observer, _) => + { + await observer.OnCompletedAsync(new(expectedError)); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.LastOrDefaultAsync()) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(SourceFailedMessage); + } + + /// Tests SingleAsync returns single element. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAsync_ThenReturnsSingleElement() + { + const int SingleValue = 42; + var result = await SignalAsync.Return(SingleValue).SingleAsync(); + await Assert.That(result).IsEqualTo(SingleValue); + } + + /// Tests SingleAsync multiple elements throws. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenSingleAsyncMultipleElements_ThenThrowsInvalidOperation() + { + const int MultipleElementCount = 3; + await Assert.That(static async () => await SignalAsync.Range(1, MultipleElementCount).SingleAsync()) + .ThrowsExactly(); + } + + /// Tests SingleAsync reports an empty source with the no-elements message. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAsyncOnEmpty_ThenThrowsInvalidOperation() + { + var ex = await Assert.That(static async () => await SignalAsync.Empty().SingleAsync()) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(NoElementsMessage); + } + + /// Tests SingleAsync reports an unmatched predicate with the no-matching-elements message. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleAsyncWithPredicateNoMatch_ThenThrowsInvalidOperationWithMatchingMessage() + { + var ex = await Assert.That( + static async () => await SignalAsync.Range(1, SourceValueCount) + .SingleAsync(static x => x > UnmatchableThreshold)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(NoMatchingElementsMessage); + } + + /// Tests SingleOrDefault on empty returns default. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleOrDefaultOnEmpty_ThenReturnsDefault() + { + var result = await SignalAsync.Empty().SingleOrDefaultAsync(); + await Assert.That(result).IsEqualTo(0); + } + + /// Tests SingleOrDefaultAsync with predicate returns matching element. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleOrDefaultAsyncWithPredicate_ThenReturnsMatchingElement() + { + const int ExpectedMatch = 3; + var result = await SignalAsync.Range(1, SourceValueCount).SingleOrDefaultAsync(static x => x == ExpectedMatch, -1); + await Assert.That(result).IsEqualTo(ExpectedMatch); + } + + /// Tests SingleOrDefaultAsync with predicate and no match returns default value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleOrDefaultAsyncWithPredicateNoMatch_ThenReturnsDefaultValue() + { + var result = await SignalAsync.Range(1, SourceValueCount).SingleOrDefaultAsync(static x => x > UnmatchableThreshold, -1); + await Assert.That(result).IsEqualTo(-1); + } + + /// Tests SingleOrDefaultAsync with predicate matching multiple elements throws. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenSingleOrDefaultAsyncWithPredicateMultipleMatches_ThenThrowsInvalidOperation() + { + const int SourceCount = 5; + const int Threshold = 2; + await Assert + .That(static async () => + await SignalAsync.Range(1, SourceCount).SingleOrDefaultAsync(static x => x > Threshold, -1)) + .ThrowsExactly(); + } + + /// Tests SingleOrDefaultAsync with no predicate and multiple elements throws. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenSingleOrDefaultAsyncMultipleElements_ThenThrowsInvalidOperation() + { + const int MultipleElementCount = 3; + await Assert.That( + static async () => await SignalAsync.Range(1, MultipleElementCount).SingleOrDefaultAsync(0)) + .ThrowsExactly(); + } + + /// Tests SingleOrDefaultAsync with custom default value on empty returns that default. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleOrDefaultAsyncWithDefaultValueOnEmpty_ThenReturnsCustomDefault() + { + const int CustomDefault = 99; + var result = await SignalAsync.Empty().SingleOrDefaultAsync(CustomDefault); + await Assert.That(result).IsEqualTo(CustomDefault); + } + + /// Tests SingleOrDefaultAsync propagates error from OnErrorResumeAsync. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenSingleOrDefaultAsyncSourceEmitsErrorResume_ThenThrows() + { + var source = SignalAsync.Create(static async (observer, ct) => + { + await observer.OnErrorResumeAsync(new InvalidOperationException("resume"), ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + await Assert.That(async () => await source.SingleOrDefaultAsync()).ThrowsExactly(); + } + + /// Tests SingleOrDefaultAsync propagates error from source completing with failure. + /// A task representing the asynchronous operation. + [Test] + public async Task WhenSingleOrDefaultAsyncSourceCompletesWithError_ThenThrows() + { + var source = SignalAsync.Create(static async (observer, _) => + { + await observer.OnCompletedAsync(Result.Failure(new InvalidOperationException("fail"))); + return DisposableAsync.Empty; + }); + await Assert.That(async () => await source.SingleOrDefaultAsync()).ThrowsExactly(); + } + + /// Tests SingleOrDefaultAsync without predicate reports correct message when multiple elements exist. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleOrDefaultAsyncMultipleElementsNoPredicate_ThenMessageReportsMoreThanOneElement() + { + var ex = await Assert.That( + static async () => await SignalAsync.Range(1, ShortSourceValueCount).SingleOrDefaultAsync(0)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(MoreThanOneElementMessage); + } + + /// Tests SingleOrDefaultAsync with predicate reports correct message when multiple elements match. + /// A representing the asynchronous test operation. + [Test] + public async Task + WhenSingleOrDefaultAsyncMultipleMatchesWithPredicate_ThenMessageReportsMoreThanOneMatchingElement() + { + var ex = await Assert.That( + static async () => await SignalAsync.Range(1, SourceValueCount) + .SingleOrDefaultAsync(static x => x > MultiMatchThreshold, -1)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo(MoreThanOneMatchingElementMessage); + } + + /// Tests SingleOrDefaultAsync propagates error from OnErrorResumeAsync with the defaultValue overload. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleOrDefaultAsyncWithDefaultValueSourceEmitsErrorResume_ThenThrowsWithCorrectMessage() + { + InvalidOperationException expectedError = new("resume error detail"); + var source = SignalAsync.Create(async (observer, ct) => + { + await observer.OnErrorResumeAsync(expectedError, ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.SingleOrDefaultAsync(0)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo("resume error detail"); + } + + /// Tests SingleOrDefaultAsync propagates failure result from OnCompletedAsync with the defaultValue overload. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSingleOrDefaultAsyncWithDefaultValueSourceCompletesWithFailure_ThenThrowsWithCorrectMessage() + { + InvalidOperationException expectedError = new("completion failure detail"); + var source = SignalAsync.Create(async (observer, _) => + { + await observer.OnCompletedAsync(Result.Failure(expectedError)); + return DisposableAsync.Empty; + }); + var ex = await Assert.That(async () => await source.SingleOrDefaultAsync(0)) + .ThrowsExactly(); + await Assert.That(ex!.Message).IsEqualTo("completion failure detail"); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.OverloadShortcuts.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.OverloadShortcuts.cs index 049ab4e3..aa7c36b6 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.OverloadShortcuts.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.OverloadShortcuts.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// Direct coverage for the cancellation-token / comparer "shortcut" overloads on the -/// terminal async operators (CountAsync, LongCountAsync, FirstOrDefaultAsync, -/// LastOrDefaultAsync, SingleOrDefaultAsync, ContainsAsync). Each shortcut -/// forwards to the full overload with a defaulted optional argument and was previously uncovered. +/// Tests cancellation-token and comparer overloads of terminal async operators. public partial class TerminalOperatorTests { /// Exercises the CountAsync(cancellationToken) overload — the no-predicate diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ReduceCoverage.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ReduceCoverage.cs index b0bb2d6a..aff9973c 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ReduceCoverage.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.ReduceCoverage.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// Coverage tests for ReduceAsync compatibility overloads and async witness failure paths. +/// Tests ReduceAsync compatibility overloads and async witness failures. public partial class TerminalOperatorTests { /// Expected sum for the sequence 1, 2, 3. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.cs index 55cd7a9e..97112c14 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TerminalOperatorTests.cs @@ -64,511 +64,12 @@ public partial class TerminalOperatorTests /// Divisor of the even-number predicate. private const int EvenDivisor = 2; - /// Hoisted source array used by tests (was inline literal). + /// Strings with distinct lengths for dictionary tests. private static readonly string[] SequenceABbCcc = ["a", "bb", "ccc"]; - /// Hoisted source array used by tests (was inline literal). + /// Strings used by key and element selector tests. private static readonly string[] SequenceHelloWorld = ["Hello", "World"]; - /// Tests FirstAsync returns first element. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstAsync_ThenReturnsFirstElement() - { - const int ExpectedFirst = 10; - var result = await SignalAsync.Range(ExpectedFirst, ShortSourceValueCount).FirstAsync(); - await Assert.That(result).IsEqualTo(ExpectedFirst); - } - - /// Tests FirstAsync with predicate returns first match. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstAsyncWithPredicate_ThenReturnsFirstMatch() - { - const int ExpectedFirstMatch = 4; - var result = await SignalAsync.Range(1, SourceValueCount).FirstAsync(static x => x > MatchThreshold); - await Assert.That(result).IsEqualTo(ExpectedFirstMatch); - } - - /// Tests FirstAsync on empty throws InvalidOperationException with the no-elements - /// message — exercises the predicate-null branch of FirstTaskObserver.OnCompletedAsyncCore. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstAsyncOnEmpty_ThenThrowsInvalidOperation() - { - var ex = await Assert.That(static async () => await SignalAsync.Empty().FirstAsync()) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(NoElementsMessage); - } - - /// Tests FirstAsync with predicate when no elements match throws InvalidOperationException with matching message. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstAsyncWithPredicateNoMatch_ThenThrowsInvalidOperationWithMatchingMessage() - { - var ex = await Assert.That( - static async () => await SignalAsync.Range(1, SourceValueCount) - .FirstAsync(static x => x > UnmatchableThreshold)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(NoMatchingElementsMessage); - } - - /// Tests FirstAsync propagates error from OnErrorResumeAsync. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstAsyncSourceEmitsErrorResume_ThenThrowsSourceException() - { - InvalidOperationException expectedError = new(ResumeErrorMessage); - var source = SignalAsync.Create(async (observer, ct) => - { - await observer.OnErrorResumeAsync(expectedError, ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.FirstAsync()).ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); - } - - /// Tests FirstAsync propagates error when source completes with failure result. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstAsyncSourceCompletesWithFailure_ThenThrowsSourceException() - { - InvalidOperationException expectedError = new(SourceFailedMessage); - var source = SignalAsync.Create(async (observer, _) => - { - await observer.OnCompletedAsync(new(expectedError)); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.FirstAsync()).ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(SourceFailedMessage); - } - - /// Tests FirstOrDefault on empty returns default. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstOrDefaultOnEmpty_ThenReturnsDefault() - { - var result = await SignalAsync.Empty().FirstOrDefaultAsync(); - await Assert.That(result).IsEqualTo(0); - } - - /// Tests FirstOrDefault with predicate match returns first. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstOrDefaultWithMatch_ThenReturnsFirst() - { - const int ExpectedFirstMatch = 4; - var result = await SignalAsync.Range(1, SourceValueCount).Where(static x => x > MatchThreshold).FirstOrDefaultAsync(0); - await Assert.That(result).IsEqualTo(ExpectedFirstMatch); - } - - /// Tests FirstOrDefaultAsync with predicate returns first matching element. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstOrDefaultAsyncWithPredicate_ThenReturnsFirstMatch() - { - const int ExpectedFirstMatch = 4; - var result = await SignalAsync.Range(1, SourceValueCount).FirstOrDefaultAsync(static x => x > MatchThreshold, -1); - await Assert.That(result).IsEqualTo(ExpectedFirstMatch); - } - - /// Tests FirstOrDefaultAsync with predicate and no match returns specified default value. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstOrDefaultAsyncWithPredicateNoMatch_ThenReturnsDefaultValue() - { - var result = await SignalAsync.Range(1, SourceValueCount).FirstOrDefaultAsync(static x => x > UnmatchableThreshold, -1); - await Assert.That(result).IsEqualTo(-1); - } - - /// Tests FirstOrDefaultAsync with predicate propagates error from OnErrorResumeAsync. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstOrDefaultAsyncWithPredicateSourceEmitsErrorResume_ThenThrows() - { - InvalidOperationException expectedError = new(ResumeErrorMessage); - var source = SignalAsync.Create(async (observer, ct) => - { - await observer.OnErrorResumeAsync(expectedError, ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.FirstOrDefaultAsync(static x => x > 0, -1)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); - } - - /// Tests FirstOrDefaultAsync propagates error from OnErrorResumeAsync. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenFirstOrDefaultAsyncSourceEmitsErrorResume_ThenThrows() - { - InvalidOperationException expectedError = new(ResumeErrorMessage); - var source = SignalAsync.Create(async (observer, ct) => - { - await observer.OnErrorResumeAsync(expectedError, ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.FirstOrDefaultAsync()) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); - } - - /// Tests LastAsync returns last element. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastAsync_ThenReturnsLastElement() - { - const int ExpectedLast = 5; - var result = await SignalAsync.Range(1, SourceValueCount).LastAsync(); - await Assert.That(result).IsEqualTo(ExpectedLast); - } - - /// Tests LastAsync with predicate returns last match. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastAsyncWithPredicate_ThenReturnsLastMatch() - { - const int ExpectedLastMatch = 3; - var result = await SignalAsync.Range(1, SourceValueCount).LastAsync(static x => x < UpperMatchBound); - await Assert.That(result).IsEqualTo(ExpectedLastMatch); - } - - /// Tests LastAsync on empty throws. - /// A task representing the asynchronous operation. - [Test] - public async Task WhenLastAsyncOnEmpty_ThenThrowsInvalidOperation() => await Assert - .That(static async () => await SignalAsync.Empty().LastAsync()) - .ThrowsExactly(); - - /// Tests LastAsync with predicate and no match throws with matching-elements message. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastAsyncWithPredicateNoMatch_ThenThrowsWithMatchingMessage() - { - var ex = await Assert.That( - static async () => await SignalAsync.Range(1, SourceValueCount) - .LastAsync(static x => x > UnmatchableThreshold)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(NoMatchingElementsMessage); - } - - /// Tests LastAsync on empty throws with no-elements message. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastAsyncOnEmpty_ThenThrowsWithNoElementsMessage() - { - var ex = await Assert.That(static async () => await SignalAsync.Empty().LastAsync()) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(NoElementsMessage); - } - - /// Tests LastAsync propagates error from OnErrorResumeAsync. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastAsyncSourceEmitsErrorResume_ThenThrowsSourceException() - { - InvalidOperationException expectedError = new(ResumeErrorMessage); - var source = SignalAsync.Create(async (observer, ct) => - { - await observer.OnNextAsync(1, ct); - await observer.OnErrorResumeAsync(expectedError, ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.LastAsync()).ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); - } - - /// Tests LastAsync propagates error when source completes with failure result. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastAsyncSourceCompletesWithFailure_ThenThrowsSourceException() - { - InvalidOperationException expectedError = new(SourceFailedMessage); - var source = SignalAsync.Create(async (observer, _) => - { - await observer.OnCompletedAsync(new(expectedError)); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.LastAsync()).ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(SourceFailedMessage); - } - - /// Tests LastOrDefault on empty returns default. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastOrDefaultOnEmpty_ThenReturnsDefault() - { - var result = await SignalAsync.Empty().LastOrDefaultAsync(); - await Assert.That(result).IsEqualTo(0); - } - - /// Tests LastOrDefaultAsync with predicate returns the last matching element. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastOrDefaultAsyncWithPredicate_ThenReturnsLastMatch() - { - const int ExpectedLastMatch = 3; - var result = await SignalAsync.Range(1, SourceValueCount).LastOrDefaultAsync(static x => x < UpperMatchBound, -1); - await Assert.That(result).IsEqualTo(ExpectedLastMatch); - } - - /// Tests LastOrDefaultAsync with predicate returns default when no elements match. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastOrDefaultAsyncWithPredicateNoMatch_ThenReturnsDefaultValue() - { - var result = await SignalAsync.Range(1, SourceValueCount).LastOrDefaultAsync(static x => x > UnmatchableThreshold, -1); - await Assert.That(result).IsEqualTo(-1); - } - - /// Tests LastOrDefaultAsync with predicate on empty returns default value. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastOrDefaultAsyncWithPredicateOnEmpty_ThenReturnsDefaultValue() - { - const int DefaultValue = 42; - var result = await SignalAsync.Empty().LastOrDefaultAsync(static _ => true, DefaultValue); - await Assert.That(result).IsEqualTo(DefaultValue); - } - - /// Tests LastOrDefaultAsync with custom default value on empty returns that default. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastOrDefaultAsyncWithDefaultValueOnEmpty_ThenReturnsCustomDefault() - { - const int CustomDefault = 99; - var result = await SignalAsync.Empty().LastOrDefaultAsync(CustomDefault); - await Assert.That(result).IsEqualTo(CustomDefault); - } - - /// Tests LastOrDefaultAsync propagates error from OnErrorResumeAsync. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastOrDefaultAsyncSourceEmitsErrorResume_ThenThrows() - { - InvalidOperationException expectedError = new(ResumeErrorMessage); - var source = SignalAsync.Create(async (observer, ct) => - { - await observer.OnNextAsync(1, ct); - await observer.OnErrorResumeAsync(expectedError, ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.LastOrDefaultAsync()) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(ResumeErrorMessage); - } - - /// Tests LastOrDefaultAsync propagates error when source completes with failure. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenLastOrDefaultAsyncSourceCompletesWithFailure_ThenThrows() - { - InvalidOperationException expectedError = new(SourceFailedMessage); - var source = SignalAsync.Create(async (observer, _) => - { - await observer.OnCompletedAsync(new(expectedError)); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.LastOrDefaultAsync()) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(SourceFailedMessage); - } - - /// Tests SingleAsync returns single element. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAsync_ThenReturnsSingleElement() - { - const int SingleValue = 42; - var result = await SignalAsync.Return(SingleValue).SingleAsync(); - await Assert.That(result).IsEqualTo(SingleValue); - } - - /// Tests SingleAsync multiple elements throws. - /// A task representing the asynchronous operation. - [Test] - public async Task WhenSingleAsyncMultipleElements_ThenThrowsInvalidOperation() - { - const int MultipleElementCount = 3; - await Assert.That(static async () => await SignalAsync.Range(1, MultipleElementCount).SingleAsync()) - .ThrowsExactly(); - } - - /// Tests SingleAsync on empty throws — exercises the predicate-null branch of - /// SingleElementObserver.OnCompletedAsyncCore's message construction. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAsyncOnEmpty_ThenThrowsInvalidOperation() - { - var ex = await Assert.That(static async () => await SignalAsync.Empty().SingleAsync()) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(NoElementsMessage); - } - - /// Tests SingleAsync with predicate on no-match throws — exercises the - /// predicate-non-null branch of SingleElementObserver.OnCompletedAsyncCore. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleAsyncWithPredicateNoMatch_ThenThrowsInvalidOperationWithMatchingMessage() - { - var ex = await Assert.That( - static async () => await SignalAsync.Range(1, SourceValueCount) - .SingleAsync(static x => x > UnmatchableThreshold)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(NoMatchingElementsMessage); - } - - /// Tests SingleOrDefault on empty returns default. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleOrDefaultOnEmpty_ThenReturnsDefault() - { - var result = await SignalAsync.Empty().SingleOrDefaultAsync(); - await Assert.That(result).IsEqualTo(0); - } - - /// Tests SingleOrDefaultAsync with predicate returns matching element. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleOrDefaultAsyncWithPredicate_ThenReturnsMatchingElement() - { - const int ExpectedMatch = 3; - var result = await SignalAsync.Range(1, SourceValueCount).SingleOrDefaultAsync(static x => x == ExpectedMatch, -1); - await Assert.That(result).IsEqualTo(ExpectedMatch); - } - - /// Tests SingleOrDefaultAsync with predicate and no match returns default value. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleOrDefaultAsyncWithPredicateNoMatch_ThenReturnsDefaultValue() - { - var result = await SignalAsync.Range(1, SourceValueCount).SingleOrDefaultAsync(static x => x > UnmatchableThreshold, -1); - await Assert.That(result).IsEqualTo(-1); - } - - /// Tests SingleOrDefaultAsync with predicate matching multiple elements throws. - /// A task representing the asynchronous operation. - [Test] - public async Task WhenSingleOrDefaultAsyncWithPredicateMultipleMatches_ThenThrowsInvalidOperation() - { - const int SourceCount = 5; - const int Threshold = 2; - await Assert - .That(static async () => - await SignalAsync.Range(1, SourceCount).SingleOrDefaultAsync(static x => x > Threshold, -1)) - .ThrowsExactly(); - } - - /// Tests SingleOrDefaultAsync with no predicate and multiple elements throws. - /// A task representing the asynchronous operation. - [Test] - public async Task WhenSingleOrDefaultAsyncMultipleElements_ThenThrowsInvalidOperation() - { - const int MultipleElementCount = 3; - await Assert.That( - static async () => await SignalAsync.Range(1, MultipleElementCount).SingleOrDefaultAsync(0)) - .ThrowsExactly(); - } - - /// Tests SingleOrDefaultAsync with custom default value on empty returns that default. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleOrDefaultAsyncWithDefaultValueOnEmpty_ThenReturnsCustomDefault() - { - const int CustomDefault = 99; - var result = await SignalAsync.Empty().SingleOrDefaultAsync(CustomDefault); - await Assert.That(result).IsEqualTo(CustomDefault); - } - - /// Tests SingleOrDefaultAsync propagates error from OnErrorResumeAsync. - /// A task representing the asynchronous operation. - [Test] - public async Task WhenSingleOrDefaultAsyncSourceEmitsErrorResume_ThenThrows() - { - var source = SignalAsync.Create(static async (observer, ct) => - { - await observer.OnErrorResumeAsync(new InvalidOperationException("resume"), ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - await Assert.That(async () => await source.SingleOrDefaultAsync()).ThrowsExactly(); - } - - /// Tests SingleOrDefaultAsync propagates error from source completing with failure. - /// A task representing the asynchronous operation. - [Test] - public async Task WhenSingleOrDefaultAsyncSourceCompletesWithError_ThenThrows() - { - var source = SignalAsync.Create(static async (observer, _) => - { - await observer.OnCompletedAsync(Result.Failure(new InvalidOperationException("fail"))); - return DisposableAsync.Empty; - }); - await Assert.That(async () => await source.SingleOrDefaultAsync()).ThrowsExactly(); - } - - /// Tests SingleOrDefaultAsync without predicate reports correct message when multiple elements exist. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleOrDefaultAsyncMultipleElementsNoPredicate_ThenMessageReportsMoreThanOneElement() - { - var ex = await Assert.That( - static async () => await SignalAsync.Range(1, ShortSourceValueCount).SingleOrDefaultAsync(0)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(MoreThanOneElementMessage); - } - - /// Tests SingleOrDefaultAsync with predicate reports correct message when multiple elements match. - /// A representing the asynchronous test operation. - [Test] - public async Task - WhenSingleOrDefaultAsyncMultipleMatchesWithPredicate_ThenMessageReportsMoreThanOneMatchingElement() - { - var ex = await Assert.That( - static async () => await SignalAsync.Range(1, SourceValueCount) - .SingleOrDefaultAsync(static x => x > MultiMatchThreshold, -1)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo(MoreThanOneMatchingElementMessage); - } - - /// Tests SingleOrDefaultAsync propagates error from OnErrorResumeAsync with the defaultValue overload. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleOrDefaultAsyncWithDefaultValueSourceEmitsErrorResume_ThenThrowsWithCorrectMessage() - { - InvalidOperationException expectedError = new("resume error detail"); - var source = SignalAsync.Create(async (observer, ct) => - { - await observer.OnErrorResumeAsync(expectedError, ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.SingleOrDefaultAsync(0)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo("resume error detail"); - } - - /// Tests SingleOrDefaultAsync propagates failure result from OnCompletedAsync with the defaultValue overload. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSingleOrDefaultAsyncWithDefaultValueSourceCompletesWithFailure_ThenThrowsWithCorrectMessage() - { - InvalidOperationException expectedError = new("completion failure detail"); - var source = SignalAsync.Create(async (observer, _) => - { - await observer.OnCompletedAsync(Result.Failure(expectedError)); - return DisposableAsync.Empty; - }); - var ex = await Assert.That(async () => await source.SingleOrDefaultAsync(0)) - .ThrowsExactly(); - await Assert.That(ex!.Message).IsEqualTo("completion failure detail"); - } - /// Tests CountAsync returns element count. /// A representing the asynchronous test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs index 1415c0ca..aa33dac9 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TimeBasedOperatorTests.cs @@ -2,1021 +2,450 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics; using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Async.Signals; namespace ReactiveUI.Primitives.Async.Tests; -/// Tests for time-based operators: Throttle, Delay, Timeout, Timer, Interval. +/// Tests timer-controlled emissions, deadlines, and cancellation. public class TimeBasedOperatorTests { - /// Message of the resumable error raised by the source. - private const string TestErrorMessage = "test error"; + /// The resumable source failure message. + private const string SourceErrorMessage = "source error"; - /// Message thrown by an observer from the immediate-fire callback. - private const string ImmediateFireObserverExplodedMessage = "immediate fire observer exploded"; + /// The virtual delay requested by the operators. + private static readonly TimeSpan Window = TimeSpan.FromSeconds(1); - /// Message thrown by an observer from its completion callback. - private const string CompletionFailedMessage = "completion-failed"; - - /// Expected value42 for assertions. - private const int ExpectedValue42 = 42; - - /// Fallback value99 (99). - private const int FallbackValue99 = 99; - - /// Maximum time a test waits for an emission that is expected to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(10); - - /// Maximum time a test waits for a completion or an unhandled-exception report. - private static readonly TimeSpan CompletionWaitTimeout = TimeSpan.FromSeconds(5); - - /// Window given to confirm that no further emissions arrive after disposal. - private static readonly TimeSpan NoFurtherEmissionWindow = TimeSpan.FromMilliseconds(200); - - /// Throttle window used by the tests that emit a burst and expect one value. - private static readonly TimeSpan BurstThrottleWindow = TimeSpan.FromMilliseconds(100); - - /// Throttle window used by the tests that wait for the throttled value to land. - private static readonly TimeSpan ThrottleWindow = TimeSpan.FromMilliseconds(50); - - /// Throttle window used by the custom-time-provider supersede test. - private static readonly TimeSpan CustomProviderThrottleWindow = TimeSpan.FromMilliseconds(80); - - /// Throttle window long enough for the test to supersede the pending value. - private static readonly TimeSpan SupersedingThrottleWindow = TimeSpan.FromMilliseconds(200); - - /// Throttle window long enough that an error arrives before the pending value fires. - private static readonly TimeSpan UnfiredThrottleWindow = TimeSpan.FromMilliseconds(500); - - /// Delay applied to a single emission. - private static readonly TimeSpan EmissionDelay = TimeSpan.FromMilliseconds(100); - - /// Lower bound the delayed emission's elapsed time must clear. - private static readonly TimeSpan MinimumEmissionDelay = TimeSpan.FromMilliseconds(80); - - /// Delay applied to every element of a sequence. - private static readonly TimeSpan SequenceDelay = TimeSpan.FromMilliseconds(30); - - /// Timeout short enough that a never-emitting source trips it. - private static readonly TimeSpan ExpiringTimeout = TimeSpan.FromMilliseconds(100); - - /// Timeout short enough that a never-emitting source trips it immediately. - private static readonly TimeSpan ImmediateTimeout = TimeSpan.FromMilliseconds(10); - - /// Timeout long enough that a prompt source never trips it. - private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(5); - - /// Timeout long enough that only the source's error can end the wait. - private static readonly TimeSpan NonExpiringTimeout = TimeSpan.FromSeconds(30); - - /// Interval between the ticks of the periodic-timer tests. - private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(20); - - /// Interval between the ticks of the cancellation tests. - private static readonly TimeSpan FastTickInterval = TimeSpan.FromMilliseconds(10); - - /// Tests Throttle only last in burst is emitted. - /// A representing the asynchronous test operation. + /// Only the latest value survives when all pending debounce callbacks run. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottle_ThenOnlyLastInBurstIsEmitted() + public async Task WhenThrottleReceivesRapidValues_ThenOnlyEmitsLatest() { - var signal = Signal.Create(); - List results = []; - await using var sub = await signal.Values.Throttle(BurstThrottleWindow).SubscribeAsync( - (x, _) => - { - results.Add(x); - return default; - }, - null); const int SecondValue = 2; - const int LastValue = 3; - await signal.OnNextAsync(1, CancellationToken.None); - await signal.OnNextAsync(SecondValue, CancellationToken.None); - await signal.OnNextAsync(LastValue, CancellationToken.None); - var resultReceived = - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count == 1, WaitTimeout); - await signal.OnCompletedAsync(Result.Success); - await Assert.That(resultReceived).IsTrue(); - await Assert.That(results).Count().IsEqualTo(1); - await Assert.That(results[0]).IsEqualTo(LastValue); - } - - /// Tests Throttle with spaced items all are emitted. - /// A representing the asynchronous test operation. + const int ThirdValue = 3; + ManualTimeProvider time = new(); + List values = []; + CallbackWitnessAsync observer = new((value, _) => + { + values.Add(value); + return default; + }); + await using SignalAsyncExtensions.ThrottleSignal.ThrottleWitness witness = new(observer, Window, time); + var firstPending = witness.StartDelayAsync(1, CancellationToken.None); + var secondPending = witness.StartDelayAsync(SecondValue, CancellationToken.None); + var thirdPending = witness.StartDelayAsync(ThirdValue, CancellationToken.None); + var first = await time.NextTimerAsync(); + var second = await time.NextTimerAsync(); + var third = await time.NextTimerAsync(); + await Assert.That(values).IsEmpty(); + await Assert.That(first.DueTime).IsEqualTo(Window); + first.Fire(); + await firstPending; + second.Fire(); + await secondPending; + third.Fire(); + await thirdPending; + await Assert.That(values).IsCollectionEqualTo([ThirdValue]); + } + + /// Each quiet period forwards its own value. + /// A task representing the asynchronous test. [Test] public async Task WhenThrottleWithSpacedItems_ThenAllAreEmitted() { - var signal = Signal.Create(); - List results = []; - TaskCompletionSource firstReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource secondReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await signal.Values.Throttle(ThrottleWindow).SubscribeAsync( - (x, _) => - { - results.Add(x); - if (results.Count == 1) - { - IgnoredResult.Of(firstReceived.TrySetResult(true)); - } - else if (results.Count == 2) - { - IgnoredResult.Of(secondReceived.TrySetResult(true)); - } - - return default; - }, - null); - const int SpacingDelayMillis = 75; const int SecondValue = 2; - await signal.OnNextAsync(1, CancellationToken.None); - await firstReceived.Task; - await Task.Delay(SpacingDelayMillis); - await signal.OnNextAsync(SecondValue, CancellationToken.None); - await secondReceived.Task; - await Assert.That(results).IsCollectionEqualTo([1, SecondValue]); - } - - /// Tests Throttle negative due time throws. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [Test] - public void WhenThrottleNegativeDueTime_ThenThrowsArgumentOutOfRange() => - Assert.Throws( - static () => SignalAsync.Return(1).Throttle(TimeSpan.FromMilliseconds(-1))); - - /// Tests Delay elements are time shifted. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDelay_ThenElementsAreTimeShifted() - { - var start = Stopwatch.GetTimestamp(); - var result = await SignalAsync.Return(ExpectedValue42).Delay(EmissionDelay).FirstAsync(); - var elapsed = Stopwatch.GetElapsedTime(start); - await Assert.That(result).IsEqualTo(ExpectedValue42); - await Assert.That(elapsed).IsGreaterThanOrEqualTo(MinimumEmissionDelay); - } - - /// Tests Delay zero causes no delay. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDelayZero_ThenNoDelay() - { - var result = await SignalAsync.Return(ExpectedValue42).Delay(TimeSpan.Zero).FirstAsync(); - await Assert.That(result).IsEqualTo(ExpectedValue42); - } - - /// Tests Delay negative throws. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [Test] - public void WhenDelayNegative_ThenThrowsArgumentOutOfRange() => - Assert.Throws(static () => SignalAsync.Return(1).Delay(TimeSpan.FromMilliseconds(-1))); - - /// Tests Delay sequence delays all elements. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDelaySequence_ThenAllElementsDelayed() - { - const int ExpectedSecond = 2; - const int ExpectedThird = 3; - const int SourceValueCount = 3; - - var result = await SignalAsync.Range(1, SourceValueCount).Delay(SequenceDelay).ToListAsync(); - await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); - } + ManualTimeProvider time = new(); + List values = []; + CallbackWitnessAsync observer = new((value, _) => + { + values.Add(value); + return default; + }); + await using SignalAsyncExtensions.ThrottleSignal.ThrottleWitness witness = new(observer, Window, time); + await time.RunAsync(witness.StartDelayAsync(1, CancellationToken.None)); + await time.RunAsync(witness.StartDelayAsync(SecondValue, CancellationToken.None)); + await Assert.That(values).IsCollectionEqualTo([1, SecondValue]); + } + + /// Errors, completion, and disposal invalidate pending debounce identifiers. + /// The terminal transition to apply. + /// A task representing the asynchronous test. + [Test] + [Arguments("error")] + [Arguments("completion")] + [Arguments("dispose")] + public async Task WhenThrottleTerminated_ThenPendingValueIsDiscarded(string terminal) + { + ManualTimeProvider time = new(); + List values = []; + List errors = []; + List completions = []; + CallbackWitnessAsync observer = new( + (value, _) => + { + values.Add(value); + return default; + }, + (error, _) => + { + errors.Add(error); + return default; + }, + result => + { + completions.Add(result); + return default; + }); + await using SignalAsyncExtensions.ThrottleSignal.ThrottleWitness witness = new(observer, Window, time); + var pending = witness.FireAfterDelayAsync(1, 0, CancellationToken.None); + InvalidOperationException error = new(SourceErrorMessage); + if (terminal == "error") + { + await witness.OnErrorResumeAsync(error, CancellationToken.None); + await Assert.That(errors).Count().IsEqualTo(1); + await Assert.That(errors[0]).IsSameReferenceAs(error); + } + else if (terminal == "completion") + { + await witness.OnCompletedAsync(Result.Success); + await Assert.That(completions).Count().IsEqualTo(1); + } + else + { + await witness.DisposeAsync(); + } - /// Tests Timeout not exceeded completes normally. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenTimeoutNotExceeded_ThenCompletesNormally() - { - var result = await SignalAsync.Return(ExpectedValue42).Timeout(GenerousTimeout).FirstAsync(); - await Assert.That(result).IsEqualTo(ExpectedValue42); + await time.RunAsync(pending); + await Assert.That(values).IsEmpty(); } - /// Tests Timeout exceeded throws TimeoutException. - /// A task representing the asynchronous operation. + /// Observer failures from delayed emission reach the unhandled exception handler. + /// A task representing the asynchronous test. [Test] - public async Task WhenTimeoutExceeded_ThenThrowsTimeoutException() + public async Task WhenThrottleOnNextThrows_ThenRoutedToUnhandledExceptionHandler() { - var source = SignalAsync.Never().Timeout(ExpiringTimeout); - await Assert.That(async () => await source.FirstAsync()).ThrowsExactly(); + ManualTimeProvider time = new(); + using UnhandledExceptionCapture capture = new(); + CallbackWitnessAsync observer = new(static (_, _) => throw new InvalidOperationException("observer failed")); + await using SignalAsyncExtensions.ThrottleSignal.ThrottleWitness witness = new(observer, Window, time); + await time.RunAsync(witness.FireAfterDelayAsync(1, 0, CancellationToken.None)); + var exception = await capture.WaitForAsync("observer failed"); + await Assert.That(exception).IsTypeOf(); } - /// Tests Timeout with fallback switches to fallback. - /// A representing the asynchronous test operation. + /// Cancellation of a pending debounce delay does not report an unhandled failure. + /// A task representing the asynchronous test. [Test] - public async Task WhenTimeoutWithFallback_ThenSwitchesToFallback() + public async Task WhenThrottleDelayCancelled_ThenNoValueIsForwarded() { - var source = SignalAsync.Never().Timeout(ExpiringTimeout, SignalAsync.Return(FallbackValue99)); - var result = await source.FirstAsync(); - await Assert.That(result).IsEqualTo(FallbackValue99); + ManualTimeProvider time = new(); + using CancellationTokenSource cancellation = new(); + List values = []; + CallbackWitnessAsync observer = new((value, _) => + { + values.Add(value); + return default; + }); + await using SignalAsyncExtensions.ThrottleSignal.ThrottleWitness witness = new(observer, Window, time); + var pending = witness.FireAfterDelayAsync(1, 0, cancellation.Token); + await cancellation.CancelAsync(); + await pending; + await Assert.That(values).IsEmpty(); } - /// Tests Timeout zero duration throws. - [MethodImpl(MethodImplOptions.AggressiveInlining)] + /// An element stays pending until its registered delay fires. + /// A task representing the asynchronous test. [Test] - public void WhenTimeoutZeroDuration_ThenThrowsArgumentOutOfRange() => - Assert.Throws(static () => SignalAsync.Return(1).Timeout(TimeSpan.Zero)); - - /// Tests Timeout negative duration throws. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [Test] - public void WhenTimeoutNegativeDuration_ThenThrowsArgumentOutOfRange() => - Assert.Throws( - static () => SignalAsync.Return(1).Timeout(TimeSpan.FromMilliseconds(-1))); - - /// Tests Timeout with null fallback throws. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - [Test] - public void WhenTimeoutWithFallbackNull_ThenThrowsArgumentNull() => Assert.Throws( - static () => SignalAsync.Return(1).Timeout(TimeSpan.FromSeconds(1), (IObservableAsync)null!)); - - /// - /// Verifies that when the downstream observer throws a non-cancellation exception - /// during OnNext from a throttled delay callback, the exception is routed to the - /// . - /// This covers the Throttle exception routing path. - /// - /// A representing the asynchronous test operation. - [Test] - public async Task WhenThrottleOnNextThrows_ThenRoutedToUnhandledExceptionHandler() + public async Task WhenDelay_ThenElementsAreTimeShifted() { - using UnhandledExceptionCapture unhandled = new(); - var signal = Signal.Create(); - await using var sub = await signal.Values.Throttle(ThrottleWindow) - .SubscribeAsync(static (_, _) => throw new InvalidOperationException("observer exploded"), null); - await signal.OnNextAsync(ExpectedValue42, CancellationToken.None); - var exception = await unhandled.WaitForAsync("observer exploded", WaitTimeout); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!).IsTypeOf(); + const int ExpectedValue = 42; + ManualTimeProvider time = new(); + var result = SignalAsync.Return(ExpectedValue).Delay(Window, time).FirstAsync().AsTask(); + var timer = await time.NextTimerAsync(); + await Assert.That(result.IsCompleted).IsFalse(); + await Assert.That(timer.DueTime).IsEqualTo(Window); + timer.Fire(); + await Assert.That(await result).IsEqualTo(ExpectedValue); } - /// - /// Verifies that when the downstream observer throws a non-cancellation exception - /// during OnCompleted from a timeout callback, the exception is routed to the - /// . - /// This covers the Timeout OnCompleted exception routing path. - /// - /// A representing the asynchronous test operation. + /// Every element requests its own delay and preserves source order. + /// A task representing the asynchronous test. [Test] - public async Task WhenTimeoutOnCompletedThrows_ThenRoutedToUnhandledExceptionHandler() + public async Task WhenDelaySequence_ThenAllElementsDelayed() { - using UnhandledExceptionCapture unhandled = new(); - var source = SignalAsync.Never().Timeout(ThrottleWindow); - await using var sub = await source.SubscribeAsync( - static (_, _) => default, - null, - static _ => throw new InvalidOperationException("completion handler exploded")); - var exception = await unhandled.WaitForAsync("completion handler exploded", WaitTimeout); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!).IsTypeOf(); + const int ThirdValue = 3; + const int SecondValue = 2; + ManualTimeProvider time = new(); + var result = await time.RunAsync(SignalAsync.Range(1, ThirdValue).Delay(Window, time).ToListAsync().AsTask()); + await Assert.That(result).IsCollectionEqualTo([1, SecondValue, ThirdValue]); } - /// - /// Verifies that - /// uses the custom path when a non-system provider is supplied. - /// This covers the Interval TimeProvider code path. - /// - /// A representing the asynchronous test operation. + /// A zero delay preserves the original source instance. + /// A task representing the asynchronous test. [Test] - public async Task WhenIntervalWithNonSystemTimeProvider_ThenUsesTimerPath() + public async Task WhenDelayZero_ThenNoDelay() { - CustomTimeProvider customProvider = new(); - List results = []; - await using var sub = await SignalAsync.Interval(ThrottleWindow, customProvider).SubscribeAsync( - (x, _) => - { - results.Add(x); - return default; - }, - null); - var receivedTwo = - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 2, WaitTimeout); - const long ExpectedSecondTick = 2L; - await Assert.That(receivedTwo).IsTrue(); - await Assert.That(results[0]).IsEqualTo(1L); - await Assert.That(results[1]).IsEqualTo(ExpectedSecondTick); + const int ExpectedValue = 42; + var source = SignalAsync.Return(ExpectedValue); + await Assert.That(source.Delay(TimeSpan.Zero)).IsSameReferenceAs(source); + await Assert.That(await source.Delay(TimeSpan.Zero).FirstAsync()).IsEqualTo(ExpectedValue); } - /// - /// Verifies that a periodic - /// stops emitting values once the subscription is disposed. - /// This covers the cancellation loop exit in the periodic timer. - /// - /// A representing the asynchronous test operation. + /// Resumable errors pass through delay without creating a timer. + /// A task representing the asynchronous test. [Test] - public async Task WhenPeriodicTimerCancelled_ThenStopsEmitting() + public async Task WhenDelaySourceEmitsErrorResume_ThenErrorForwarded() { - List results = []; - var sub = await SignalAsync.Timer(TimeSpan.Zero, ThrottleWindow).SubscribeAsync( - (x, _) => - { - results.Add(x); - return default; - }, - null); - var receivedTwo = - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 2, WaitTimeout); - await Assert.That(receivedTwo).IsTrue(); - var countAtDispose = results.Count; - await sub.DisposeAsync(); - - // Allow a brief window to confirm no further emissions - var noMoreEmissions = await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count == countAtDispose, - NoFurtherEmissionWindow); - await Assert.That(noMoreEmissions).IsTrue(); + ManualTimeProvider time = new(); + var source = Signal.Create(); + Exception? actual = null; + await using var subscription = await source.Values.Delay(Window, time).SubscribeAsync(static (_, _) => default, (error, _) => + { + actual = error; + return default; + }); + InvalidOperationException expected = new(SourceErrorMessage); + await source.OnErrorResumeAsync(expected, CancellationToken.None); + await Assert.That(actual).IsSameReferenceAs(expected); } - /// - /// Verifies that uses the non-system - /// code path in DelayAsync when a - /// custom provider is supplied, and still correctly debounces values. - /// - /// A representing the asynchronous test operation. + /// A source that completes before its deadline preserves its values. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottleWithCustomTimeProvider_ThenUsesTimerPath() + public async Task WhenTimeoutNotExceeded_ThenCompletesNormally() { - CustomTimeProvider customProvider = new(); - var signal = Signal.Create(); - List results = []; - TaskCompletionSource resultReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await signal.Values.Throttle(ThrottleWindow, customProvider) - .SubscribeAsync( - (x, _) => - { - results.Add(x); - IgnoredResult.Of(resultReceived.TrySetResult(true)); - return default; - }, - null); + const int ThirdValue = 3; const int SecondValue = 2; - const int LastValue = 3; - await signal.OnNextAsync(1, CancellationToken.None); - await signal.OnNextAsync(SecondValue, CancellationToken.None); - await signal.OnNextAsync(LastValue, CancellationToken.None); - await resultReceived.Task; - await signal.OnCompletedAsync(Result.Success); - await Assert.That(results).Count().IsEqualTo(1); - await Assert.That(results[0]).IsEqualTo(LastValue); + ManualTimeProvider time = new(); + var values = await SignalAsync.Range(1, ThirdValue).Timeout(Window, time).ToListAsync(); + await Assert.That(values).IsCollectionEqualTo([1, SecondValue, ThirdValue]); } - /// - /// Verifies that when two values are emitted in quick succession with a custom - /// , the first value is superseded and only - /// the second is forwarded, exercising the non-system DelayAsync path. - /// - /// A representing the asynchronous test operation. + /// Firing the deadline fails a source that has not emitted. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottleWithCustomTimeProviderValueSuperseded_ThenOlderValueDropped() + public async Task WhenTimeoutExceeded_ThenThrowsTimeoutException() { - CustomTimeProvider customProvider = new(); - var signal = Signal.Create(); - List results = []; - TaskCompletionSource resultReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await signal.Values.Throttle(CustomProviderThrottleWindow, customProvider) - .SubscribeAsync( - (x, _) => - { - results.Add(x); - IgnoredResult.Of(resultReceived.TrySetResult(true)); - return default; - }, - null); - const int FirstValue = 10; - const int LastValue = 20; - - // Emit two values rapidly; first should be superseded - await signal.OnNextAsync(FirstValue, CancellationToken.None); - await signal.OnNextAsync(LastValue, CancellationToken.None); - await resultReceived.Task; - await signal.OnCompletedAsync(Result.Success); - await Assert.That(results).Count().IsEqualTo(1); - await Assert.That(results[0]).IsEqualTo(LastValue); + ManualTimeProvider time = new(); + var pending = SignalAsync.Never().Timeout(Window, time).FirstAsync().AsTask(); + await Assert.That(pending.IsCompleted).IsFalse(); + await time.FireNextAsync(); + await Assert.That(() => pending).ThrowsExactly(); } - /// - /// Verifies that when the downstream observer throws during a throttled emission - /// with a custom , the exception is routed to - /// . - /// - /// A representing the asynchronous test operation. + /// A fired deadline subscribes to the fallback source. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottleWithCustomTimeProviderOnNextThrows_ThenRoutedToUnhandledExceptionHandler() + public async Task WhenTimeoutWithFallback_ThenSwitchesToFallback() { - using UnhandledExceptionCapture unhandled = new(); - CustomTimeProvider customProvider = new(); - var signal = Signal.Create(); - await using var sub = await signal.Values.Throttle(ThrottleWindow, customProvider) - .SubscribeAsync( - static (_, _) => throw new InvalidOperationException("custom provider observer exploded"), - null); - await signal.OnNextAsync(1, CancellationToken.None); - var exception = await unhandled.WaitForAsync("custom provider observer exploded", WaitTimeout); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!).IsTypeOf(); + const int FallbackValue = 99; + ManualTimeProvider time = new(); + var pending = SignalAsync.Never().Timeout(Window, SignalAsync.Return(FallbackValue), time).FirstAsync().AsTask(); + await time.FireNextAsync(); + await Assert.That(await pending).IsEqualTo(FallbackValue); } - /// - /// Verifies that when OnErrorResumeAsync is called on a throttled sequence, - /// the pending timer is cancelled and the error is forwarded to the downstream observer - /// in the Throttle operator. - /// - /// A representing the asynchronous test operation. + /// Each value rearms the same deadline timer. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottleOnErrorResume_ThenCancelsTimerAndForwardsError() - { - var signal = Signal.Create(); - List results = []; - List errors = []; - TaskCompletionSource errorReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await signal.Values.Throttle(UnfiredThrottleWindow).SubscribeAsync( - (x, _) => - { - results.Add(x); - return default; - }, - (ex, _) => - { - errors.Add(ex); - IgnoredResult.Of(errorReceived.TrySetResult(true)); - return default; - }); - - // Emit a value (starts a 500ms timer) - await signal.OnNextAsync(1, CancellationToken.None); - - // Immediately send an error before the throttle timer fires - await signal.OnErrorResumeAsync(new InvalidOperationException(TestErrorMessage), CancellationToken.None); - await errorReceived.Task; - - // Error should be forwarded, and the pending value should NOT be emitted - await Assert.That(errors).Count().IsEqualTo(1); - await Assert.That(errors[0]).IsTypeOf(); - await Assert.That(results).Count().IsEqualTo(0); - } - - /// - /// Verifies that when the throws a non-cancellation exception - /// during the delay inside OnTimeoutAsync, the exception is routed to the - /// . - /// This covers the Timeout delay exception routing path. - /// - /// A representing the asynchronous test operation. - [Test] - public async Task WhenTimeoutDelayThrowsNonCancellation_ThenRoutedToUnhandledExceptionHandler() + public async Task WhenTimeoutResetsOnValue_ThenDoesNotFire() { - using UnhandledExceptionCapture unhandled = new(); - ThrowingTimeProvider throwingProvider = new(); - DirectSource source = new(); - await using var sub = await source.Timeout(ExpiringTimeout, throwingProvider) - .SubscribeAsync(static (_, _) => default, null); - var exception = await unhandled.WaitForAsync("timer creation failed", WaitTimeout); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!).IsTypeOf(); - } - - /// - /// Verifies that when the source emits an error via OnErrorResumeAsync, - /// the TimeoutWitness cancels the timer and forwards the error downstream. - /// This covers the OnErrorResumeAsyncCore path in the Timeout operator. - /// - /// A representing the asynchronous test operation. + const int SecondValue = 2; + ManualTimeProvider time = new(); + var source = Signal.Create(); + List values = []; + await using var subscription = await source.Values.Timeout(Window, time).SubscribeAsync((value, _) => + { + values.Add(value); + return default; + }); + var timer = await time.NextTimerAsync(); + await source.OnNextAsync(1, CancellationToken.None); + await Assert.That(timer.DueTime).IsEqualTo(Window); + await source.OnNextAsync(SecondValue, CancellationToken.None); + await Assert.That(timer.DueTime).IsEqualTo(Window); + await source.OnCompletedAsync(Result.Success); + await Assert.That(timer.DueTime).IsEqualTo(Timeout.InfiniteTimeSpan); + timer.Fire(); + await Assert.That(values).IsCollectionEqualTo([1, SecondValue]); + } + + /// A resumable source error disables the deadline and reaches the observer. + /// A task representing the asynchronous test. [Test] public async Task WhenTimeoutSourceEmitsErrorResume_ThenForwardsAndCancelsTimer() { - List errors = []; - TaskCompletionSource errorReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); - DirectSource source = new(); - await using var sub = await source.Timeout(NonExpiringTimeout).SubscribeAsync( - static (_, _) => default, - (ex, _) => - { - errors.Add(ex); - IgnoredResult.Of(errorReceived.TrySetResult(true)); - return default; - }); - InvalidOperationException testError = new(TestErrorMessage); - await source.EmitError(testError); - await errorReceived.Task; - await Assert.That(errors).Count().IsEqualTo(1); - await Assert.That(errors[0]).IsTypeOf(); - await Assert.That(errors[0].Message).IsEqualTo(TestErrorMessage); - } - - /// - /// Verifies that Delay forwards non-terminal errors via OnErrorResumeAsync. - /// Covers the OnErrorResumeAsyncCore path in DelayObserver. - /// - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDelaySourceEmitsErrorResume_ThenErrorForwarded() - { - DirectSource source = new(); - List errors = []; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await source.Delay(TimeSpan.FromMilliseconds(1)).SubscribeAsync( - static (_, _) => default, - (ex, _) => - { - errors.Add(ex); - return default; - }, - _ => - { - IgnoredResult.Of(completed.TrySetResult()); - return default; - }); - InvalidOperationException expectedError = new("resume error"); - await source.EmitError(expectedError); - await source.Complete(Result.Success); - await completed.Task; - await Assert.That(errors).Count().IsEqualTo(1); - await Assert.That(errors[0]).IsSameReferenceAs(expectedError); + ManualTimeProvider time = new(); + var source = Signal.Create(); + Exception? actual = null; + await using var subscription = await source.Values.Timeout(Window, time).SubscribeAsync(static (_, _) => default, (error, _) => + { + actual = error; + return default; + }); + var timer = await time.NextTimerAsync(); + InvalidOperationException expected = new(SourceErrorMessage); + await source.OnErrorResumeAsync(expected, CancellationToken.None); + await Assert.That(actual).IsSameReferenceAs(expected); + await Assert.That(timer.DueTime).IsEqualTo(Timeout.InfiniteTimeSpan); } - /// - /// Verifies that Throttle drops a value when superseded by a newer emission, - /// exercising the id-mismatch early return in FireAfterDelayAsync. - /// - /// A representing the asynchronous test operation. + /// Deadline completion failures reach the unhandled exception handler. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottleValueSuperseded_ThenOlderValueDropped() + public async Task WhenTimeoutOnCompletedThrows_ThenRoutedToUnhandledExceptionHandler() { - var signal = Signal.Create(); - List results = []; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await signal.Values.Throttle(SupersedingThrottleWindow).SubscribeAsync( - (x, _) => - { - results.Add(x); - return default; - }, - null, - _ => - { - IgnoredResult.Of(completed.TrySetResult()); - return default; - }); - const int LastValue = 2; - - // Emit two values in rapid succession; first should be superseded - await signal.OnNextAsync(1, CancellationToken.None); - await signal.OnNextAsync(LastValue, CancellationToken.None); - - // Wait for the throttled value to arrive before completing - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 1, CompletionWaitTimeout); - await signal.OnCompletedAsync(Result.Success); - await completed.Task; - - // Only the last value (2) should have been emitted - await Assert.That(results).Contains(LastValue); + ManualTimeProvider time = new(); + using UnhandledExceptionCapture capture = new(); + await using var subscription = await SignalAsync.Never().Timeout(Window, time).SubscribeAsync( + new ThrowingCompletionWitness(), + CancellationToken.None); + await time.FireNextAsync(); + await Assert.That(await capture.WaitForAsync("completion failed")).IsTypeOf(); } - /// - /// Verifies that Throttle routes non-cancellation exceptions to the unhandled exception handler. - /// Covers the catch(Exception) block in ThrottleWitness.FireAfterDelayAsync. - /// - /// A representing the asynchronous test operation. + /// Failure to create a deadline timer is reported without failing subscription. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottleFireThrowsNonCancellation_ThenRoutedToUnhandledHandler() + public async Task WhenTimeoutDelayThrowsNonCancellation_ThenRoutedToUnhandledExceptionHandler() { - using UnhandledExceptionCapture unhandled = new(); - InvalidOperationException expectedError = new("downstream error"); - DirectSource source = new(); - await using var sub = await source.Throttle(TimeSpan.FromMilliseconds(1)) - .SubscribeAsync((_, _) => throw expectedError, null); - await source.EmitNext(1); - var exception = await unhandled.WaitForAsync("downstream error", CompletionWaitTimeout); - await Assert.That(exception).IsNotNull(); + using UnhandledExceptionCapture capture = new(); + await using var subscription = await SignalAsync.Never().Timeout(Window, new ThrowingTimeProvider()).SubscribeAsync(static (_, _) => default); + await Assert.That(await capture.WaitForAsync("timer creation failed")).IsTypeOf(); } - /// - /// Verifies that a periodic Timer emits multiple ticks before cancellation. - /// Covers the while-loop body in the periodic Timer factory. - /// - /// A representing the asynchronous test operation. + /// Periodic timers emit zero-based ticks only when their delays are fired. + /// A task representing the asynchronous test. [Test] public async Task WhenPeriodicTimerEmitsMultipleTicks_ThenAllTicksReceived() { - List results = []; - var sub = await SignalAsync.Timer(TimeSpan.Zero, TickInterval).SubscribeAsync( - (x, _) => - { - results.Add(x); - return default; - }, - null); - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 3, WaitTimeout); - await sub.DisposeAsync(); - const int MinTickCount = 3; - const int ThirdTickIndex = 2; - const long ExpectedThirdTick = 2L; - await Assert.That(results.Count).IsGreaterThanOrEqualTo(MinTickCount); - await Assert.That(results[0]).IsEqualTo(0L); - await Assert.That(results[1]).IsEqualTo(1L); - await Assert.That(results[ThirdTickIndex]).IsEqualTo(ExpectedThirdTick); + const int ThirdValue = 3; + const long SecondTick = 2L; + ManualTimeProvider time = new(); + var pending = SignalAsync.Timer(Window, Window, time).Take(ThirdValue).ToListAsync().AsTask(); + var values = await time.RunAsync(pending); + await Assert.That(values).IsCollectionEqualTo([0L, 1L, SecondTick]); } - /// - /// Verifies that a periodic - /// with a custom emits at least two ticks before disposal, - /// exercising the loop continuation on line 90 of Timer.cs through the non-system delay path. - /// - /// A representing the asynchronous test operation. + /// Disposing a pending timer prevents the registered callback from emitting. + /// Whether to use the interval factory. + /// A task representing the asynchronous test. [Test] - public async Task WhenPeriodicTimerWithCustomTimeProvider_ThenLoopContinuesUntilDisposed() + [Arguments(false)] + [Arguments(true)] + public async Task WhenPeriodicTimerCancelled_ThenStopsEmitting(bool interval) { - CustomTimeProvider customProvider = new(); - List results = []; - var sub = await SignalAsync.Timer(TimeSpan.Zero, TickInterval, customProvider).SubscribeAsync( - (x, _) => - { - results.Add(x); - return default; - }, - null); - var receivedTwo = - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 2, WaitTimeout); - await Assert.That(receivedTwo).IsTrue(); - var countAtDispose = results.Count; - await sub.DisposeAsync(); - var noMoreEmissions = await AsyncTestHelpers.WaitForConditionAsync( - () => results.Count == countAtDispose, - NoFurtherEmissionWindow); - await Assert.That(noMoreEmissions).IsTrue(); - await Assert.That(results[0]).IsEqualTo(0L); - await Assert.That(results[1]).IsEqualTo(1L); - } - - /// - /// Verifies that when the downstream observer throws a non-cancellation exception - /// during OnNext from a throttled emission using an immediate-fire - /// , the exception is routed to the - /// . - /// This deterministically covers lines 162-163 in ThrottleWitness.FireAfterDelayAsync. - /// - /// A representing the asynchronous test operation. - [Test] - public async Task WhenThrottleImmediateFireOnNextThrows_ThenRoutedToUnhandledExceptionHandler() - { - using UnhandledExceptionCapture unhandled = new(); - ImmediateFireTimeProvider immediateProvider = new(); - var signal = Signal.Create(); - await using var sub = await signal.Values.Throttle(BurstThrottleWindow, immediateProvider) - .SubscribeAsync( - static (_, _) => throw new InvalidOperationException(ImmediateFireObserverExplodedMessage), - null); - await signal.OnNextAsync(1, CancellationToken.None); - var exception = await unhandled.WaitForAsync(ImmediateFireObserverExplodedMessage, CompletionWaitTimeout); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!).IsTypeOf(); - await Assert.That(exception!.Message).IsEqualTo(ImmediateFireObserverExplodedMessage); - } - - /// Tests Interval stops when cancelled. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenIntervalCancelled_ThenStops() - { - const int MinItemCount = 2; - CancellationTokenSource cts = new(); - List items = []; - TaskCompletionSource cancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await SignalAsync.Interval(FastTickInterval).SubscribeAsync( - async (x, _) => - { - items.Add(x); - if (x < MinItemCount) - { - return; - } - - await cts.CancelAsync(); - IgnoredResult.Of(cancelled.TrySetResult()); - }, - null, - null, - cts.Token); - await cancelled.Task; - await Assert.That(items.Count).IsGreaterThanOrEqualTo(MinItemCount); - } - - /// Tests Timer with period stops when cancelled. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenTimerWithPeriodCancelled_ThenStops() - { - const int MinItemCount = 2; - CancellationTokenSource cts = new(); - List items = []; - TaskCompletionSource cancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await SignalAsync.Timer(TimeSpan.FromMilliseconds(1), FastTickInterval) - .SubscribeAsync( - async (x, _) => - { - items.Add(x); - if (x < MinItemCount) - { - return; - } - - await cts.CancelAsync(); - IgnoredResult.Of(cancelled.TrySetResult()); - }, - null, - null, - cts.Token); - await cancelled.Task; - await Assert.That(items.Count).IsGreaterThanOrEqualTo(MinItemCount); - } - - /// Tests Throttle supersedes older values and only emits latest. - /// A representing the asynchronous test operation. + ManualTimeProvider time = new(); + List values = []; + var source = interval ? SignalAsync.Interval(Window, time) : SignalAsync.Timer(Window, Window, time); + var subscription = await source.SubscribeAsync((value, _) => + { + values.Add(value); + return default; + }); + var timer = await time.NextTimerAsync(); + await subscription.DisposeAsync(); + timer.Fire(); + await Assert.That(values).IsEmpty(); + } + + /// Cancellation completes a pending periodic delay and suppresses its registered callback. + /// Whether to use the interval subscription. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task WhenPeriodicSubscriptionCancelled_ThenPendingTickIsSuppressed(bool interval) + { + ManualTimeProvider time = new(); + using CancellationTokenSource cancellation = new(); + List values = []; + CallbackWitnessAsync observer = new((value, _) => + { + values.Add(value); + return default; + }); + await using TaskSignalSubscription subscription = interval + ? new IntervalSubscription(observer, Window, time) + : new TimerSubscription(observer, Window, Window, time); + var execution = subscription.ExecuteAsync(cancellation.Token).AsTask(); + await time.FireNextAsync(); + var pendingTimer = await time.NextTimerAsync(); + await cancellation.CancelAsync(); + await execution; + pendingTimer.Fire(); + await Assert.That(values).IsCollectionEqualTo([interval ? 1L : 0L]); + } + + /// Intervals emit consecutive values starting at one. + /// A task representing the asynchronous test. [Test] - public async Task WhenThrottleReceivesRapidValues_ThenOnlyEmitsLatest() + public async Task WhenIntervalWithNonSystemTimeProvider_ThenUsesTimerPath() { const int SecondValue = 2; - const int LastValue = 3; - ManualTimeProvider manualProvider = new(); - DirectSource source = new(); - List items = []; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await source.Throttle(ThrottleWindow, manualProvider).SubscribeAsync( - async (x, ct) => - { - _ = ct; - items.Add(x); - - // Drive completion re-entrantly from the throttled emission itself. The emission - // runs on a pooled timer continuation; completing the source from the test thread - // would race that still-unwinding OnNext call and trip the witness's concurrent-call - // guard, silently dropping the completion. Completing from inside the handler keeps - // both notifications on the same thread, where re-entrant calls are permitted. - if (x == LastValue) - { - await source.Complete(Result.Success); - } - }, - null, - _ => - { - IgnoredResult.Of(completed.TrySetResult()); - return default; - }); - await source.EmitNext(1); - await source.EmitNext(SecondValue); - await source.EmitNext(LastValue); - - await Assert.That(manualProvider.TimerCount).IsEqualTo(LastValue); - manualProvider.FireAll(); - await completed.Task; - await Assert.That(items).Contains(LastValue); + const long SecondTick = 2L; + ManualTimeProvider time = new(); + var values = await time.RunAsync(SignalAsync.Interval(Window, time).Take(SecondValue).ToListAsync().AsTask()); + await Assert.That(values).IsCollectionEqualTo([1L, SecondTick]); } - /// Tests Timeout fires when source is slow. - /// A representing the asynchronous test operation. + /// The default-provider overload constructs a throttle signal without starting a timer. + /// A task representing the asynchronous test. [Test] - public async Task WhenTimeoutFires_ThenThrowsTimeoutException() => await Assert - .That(static async () => await SignalAsync.Never().Timeout(ImmediateTimeout).FirstAsync()) - .ThrowsExactly(); + public async Task WhenThrottleUsesDefaultProvider_ThenCreatesThrottleSignal() => + await Assert.That(SignalAsync.Return(1).Throttle(Window)).IsTypeOf>(); - /// Tests Timeout with fallback observable. - /// A representing the asynchronous test operation. + /// Negative debounce intervals are rejected. + [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] - public async Task WhenTimeoutWithFallback_ThenFallbackUsed() - { - var result = await SignalAsync.Never().Timeout(ImmediateTimeout, SignalAsync.Return(FallbackValue99)) - .FirstAsync(); - await Assert.That(result).IsEqualTo(FallbackValue99); - } + public void WhenThrottleNegativeDueTime_ThenThrowsArgumentOutOfRange() => + Assert.Throws(static () => SignalAsync.Return(1).Throttle(TimeSpan.FromTicks(-1))); - /// Tests Timeout resets on each value and does not fire. - /// A representing the asynchronous test operation. + /// Negative delays are rejected. + [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] - public async Task WhenTimeoutResetsOnValue_ThenDoesNotFire() - { - const int ExpectedSecond = 2; - const int ExpectedThird = 3; - const int SourceValueCount = 3; - - var result = await SignalAsync.Range(1, SourceValueCount).Timeout(GenerousTimeout).ToListAsync(); - await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); - } + public void WhenDelayNegative_ThenThrowsArgumentOutOfRange() => + Assert.Throws(static () => SignalAsync.Return(1).Delay(TimeSpan.FromTicks(-1))); - /// Verifies that an exception thrown by the downstream observer's OnCompletedAsync during a Timeout firing is routed to . - /// A representing the asynchronous test operation. + /// Deadlines require a positive interval. + /// The invalid interval in ticks. + [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] - public async Task WhenTimeoutFiresAndDownstreamCompletionThrows_ThenRoutedToUnhandled() - { - using UnhandledExceptionCapture unhandled = new(); - TimeoutThrowingWitness throwing = new(new InvalidOperationException(CompletionFailedMessage)); - await using var sub = await SignalAsync.Never().Timeout(TimeSpan.FromMilliseconds(1)) - .SubscribeAsync(throwing, CancellationToken.None); - var exception = await unhandled.WaitForAsync(CompletionFailedMessage, CompletionWaitTimeout); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).IsEqualTo(CompletionFailedMessage); - } - - /// - /// A custom that delegates timer creation to the system provider. - /// Used to exercise the non-system code paths in Interval and Timer operators. - /// - private sealed class CustomTimeProvider : TimeProvider - { - /// Creates a timer by delegating to the system . - /// The callback to invoke when the timer fires. - /// The state object passed to the callback. - /// The initial delay before the first invocation. - /// The interval between subsequent invocations. - /// An instance. - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => - System.CreateTimer(callback, state, dueTime, period); - } + [Arguments(0L)] + [Arguments(-1L)] + public void WhenTimeoutNonPositive_ThenThrowsArgumentOutOfRange(long ticks) => + Assert.Throws(() => SignalAsync.Return(1).Timeout(TimeSpan.FromTicks(ticks))); - /// A that throws from to exercise the non-cancellation catch. - private sealed class ThrowingTimeProvider : TimeProvider - { - /// Throws an instead of creating a timer. - /// The callback (unused). - /// The state (unused). - /// The due time (unused). - /// The period (unused). - /// Never returns; always throws. - /// Always thrown. - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => - throw new InvalidOperationException("timer creation failed"); - } - - /// A that records one-shot timers and exposes an explicit fire point for deterministic debounce supersession tests. - private sealed class ManualTimeProvider : TimeProvider - { - /// Protects timer collection access. - private readonly Lock _gate = new(); - - /// The timers created by this provider. - private readonly List _timers = []; - - /// Gets the number of timers created by this provider. - internal int TimerCount - { - get - { - lock (_gate) - { - return _timers.Count; - } - } - } - - /// Creates a manual timer and stores it until is invoked. - /// The callback to invoke when the timer is fired. - /// The state object passed to the callback. - /// The initial delay (recorded by caller behavior, not elapsed by this provider). - /// The interval (ignored; timers are one-shot in these tests). - /// A manual instance. - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - _ = dueTime; - _ = period; - ManualTimer timer = new(callback, state); - lock (_gate) - { - _timers.Add(timer); - } - - return timer; - } - - /// Fires every timer that has been created so far. - internal void FireAll() - { - ManualTimer[] timers; - lock (_gate) - { - timers = [.. _timers]; - } - - foreach (var timer in timers) - { - timer.Fire(); - } - } - - /// Manual one-shot timer used by . - /// The callback to invoke. - /// The state object passed to the callback. - private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer - { - /// Non-zero once the timer has been disposed. - private int _disposed; - - /// Non-zero once the timer has fired. - private int _fired; - - /// No-op change; returns whether the timer is still active. - /// The due time (ignored). - /// The period (ignored). - /// when the timer is still active. - public bool Change(TimeSpan dueTime, TimeSpan period) - { - _ = dueTime; - _ = period; - return Volatile.Read(ref _disposed) == 0; - } - - /// Marks the timer as disposed. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Dispose() => Interlocked.Exchange(ref _disposed, 1); - - /// Marks the timer as disposed. - /// A completed . - public ValueTask DisposeAsync() - { - Dispose(); - return default; - } - - /// Invokes the callback once if the timer has not been disposed. - internal void Fire() - { - if (Volatile.Read(ref _disposed) != 0 - || Interlocked.Exchange(ref _fired, 1) != 0) - { - return; - } - - callback(state); - } - } - } - - /// - /// A that fires the timer callback synchronously during - /// , completing the delay immediately. Used to deterministically - /// test the id-mismatch early return and exception routing paths in ThrottleWitness. - /// - private sealed class ImmediateFireTimeProvider : TimeProvider - { - /// Invokes the timer callback synchronously and returns a no-op timer. - /// The callback to invoke immediately. - /// The state object passed to the callback. - /// The initial delay (ignored; fires immediately). - /// The interval (ignored; fires only once). - /// A no-op instance. - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - callback(state); - return new NoOpTimer(); - } - - /// A timer that performs no operations. Used as the return value from . - private sealed class NoOpTimer : ITimer - { - /// No-op change; returns true. - /// The due time (ignored). - /// The period (ignored). - /// Always returns true. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool Change(TimeSpan dueTime, TimeSpan period) => true; - - /// No-op dispose. - public void Dispose() - { - } - - /// No-op async dispose. - /// A completed . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ValueTask DisposeAsync() => default; - } - } + /// A fallback observable is required by the fallback overload. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [Test] + public void WhenTimeoutWithFallbackNull_ThenThrowsArgumentNull() => + Assert.Throws(static () => SignalAsync.Return(1).Timeout(Window, (IObservableAsync)null!)); - /// 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 TimeoutThrowingWitness(Exception error) : IObserverAsync + /// Throws directly from completion without an observer wrapper catching it. + private sealed class ThrowingCompletionWitness : IObserverAsync { /// - /// A task representing the asynchronous operation. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) => default; + public ValueTask OnNextAsync(int value, CancellationToken cancellationToken) => default; /// - /// A task representing the asynchronous operation. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) => default; /// - /// A task representing the asynchronous operation. - public ValueTask OnCompletedAsync(Result result) => throw error; + public ValueTask OnCompletedAsync(Result result) => throw new InvalidOperationException("completion failed"); /// - /// A task representing the asynchronous operation. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask DisposeAsync() => default; } + + /// A provider that rejects timer creation. + private sealed class ThrowingTimeProvider : TimeProvider + { + /// + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => + throw new InvalidOperationException("timer creation failed"); + } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.SideEffects.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.SideEffects.cs new file mode 100644 index 00000000..0ad3892e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.SideEffects.cs @@ -0,0 +1,205 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Async.Disposables; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests Do and Tap notification side effects. +public partial class TransformationOperatorTests +{ + /// Tests Tap and Do null callback overloads return the original source. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTapAndDoCallbacksAreNull_ThenReturnSource() + { + var source = SignalAsync.Return(1); + await Assert.That(source.Tap((Func?)null, null, null)) + .IsSameReferenceAs(source); + await Assert.That(source.Tap((Action)null!)).IsSameReferenceAs(source); + await Assert.That(source.Do((Action?)null, null, null)).IsSameReferenceAs(source); + } + + /// Tests synchronous Do callbacks receive values, resumable errors and completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDoSyncWithAllCallbacks_ThenInvokesAndForwards() + { + const int ExpectedFirst = 7; + const int ExpectedSecond = 8; + List nextValues = []; + List errors = []; + List completions = []; + TaskCompletionSource errored = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + var source = SignalAsync.Create(static async (observer, ct) => + { + await observer.OnNextAsync(ExpectedFirst, ct); + await observer.OnErrorResumeAsync(new InvalidOperationException("resume"), ct); + await observer.OnNextAsync(ExpectedSecond, ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + await using var sub = await source.Do( + nextValues.Add, + exception => + { + errors.Add(exception); + _ = errored.TrySetResult(); + }, + result => + { + completions.Add(result); + _ = completed.TrySetResult(); + }).SubscribeAsync(static (_, _) => default); + await Task.WhenAll(errored.Task, completed.Task); + await Assert.That(nextValues).IsCollectionEqualTo([ExpectedFirst, ExpectedSecond]); + await Assert.That(errors).Count().IsEqualTo(1); + await Assert.That(completions).Count().IsEqualTo(1); + } + + /// Tests Do with only a completion callback forwards resumable errors. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDoSyncWithOnlyCompletionCallbackAndSourceEmitsErrorResume_ThenForwardsError() + { + List caughtErrors = []; + List completions = []; + TaskCompletionSource done = new(TaskCreationOptions.RunContinuationsAsynchronously); + var source = SignalAsync.Create(static async (observer, ct) => + { + await observer.OnErrorResumeAsync(new InvalidOperationException("sync completion-only"), ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + await using var sub = await source.Do((Action?)null, null, completions.Add) + .SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caughtErrors.Add(ex); + return default; + }, + _ => + { + IgnoredResult.Of(done.TrySetResult()); + return default; + }); + await done.Task; + await Assert.That(caughtErrors).Count().IsEqualTo(1); + await Assert.That(completions).Count().IsEqualTo(1); + } + + /// Tests Tap with only an asynchronous completion callback invokes it. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenTapAsyncWithOnlyCompletionCallback_ThenInvokesCallback() + { + Result? completion = null; + await SignalAsync.Return(1).Tap((Func?)null, null, result => + { + completion = result; + return default; + }).WaitCompletionAsync(); + await Assert.That(completion).IsNotNull(); + await Assert.That(completion!.Value.IsSuccess).IsTrue(); + } + + /// Tests Do without callbacks forwards resumable errors and completion. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDoWithNoCallbacksAndSourceEmitsErrorResume_ThenForwardsBoth() + { + Exception? caught = null; + var completed = false; + TaskCompletionSource errorTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completionTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + var source = SignalAsync.Create(static async (observer, ct) => + { + await observer.OnErrorResumeAsync(new InvalidOperationException("resume"), ct); + await observer.OnCompletedAsync(Result.Success); + return DisposableAsync.Empty; + }); + await using var sub = await source.Do().SubscribeAsync( + static (_, _) => default, + (ex, _) => + { + caught = ex; + IgnoredResult.Of(errorTcs.TrySetResult()); + return default; + }, + _ => + { + completed = true; + IgnoredResult.Of(completionTcs.TrySetResult()); + return default; + }); + await Task.WhenAll(errorTcs.Task, completionTcs.Task); + await Assert.That(caught).IsNotNull(); + await Assert.That(completed).IsTrue(); + } + + /// Tests Do without callbacks forwards source values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDoWithNoCallbacks_ThenPassesThroughValues() + { + const int ExpectedSecond = 2; + const int ExpectedThird = 3; + const int SourceValueCount = 3; + + var result = await SignalAsync.Range(1, SourceValueCount).Do().ToListAsync(); + await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); + } + + /// Tests sync Do invokes side effects. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDoSync_ThenInvokesSideEffects() + { + const int ExpectedSecond = 2; + const int ExpectedThird = 3; + const int SourceValueCount = 3; + + List sideEffects = []; + var result = await SignalAsync.Range(1, SourceValueCount).Do((x, _) => + { + sideEffects.Add(x); + return default; + }).ToListAsync(); + await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); + await Assert.That(sideEffects).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); + } + + /// Tests async Do invokes side effects. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDoAsync_ThenInvokesSideEffects() + { + const int ExpectedSecond = 2; + const int ExpectedThird = 3; + const int SourceValueCount = 3; + + List sideEffects = []; + var result = await SignalAsync.Range(1, SourceValueCount).Do(async (x, _) => + { + await Task.Yield(); + sideEffects.Add(x); + }).ToListAsync(); + await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); + await Assert.That(sideEffects).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); + } + + /// Tests Do with completion handler invokes on completed. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDoWithCompletionHandler_ThenInvokesOnCompleted() + { + Result? completion = null; + await SignalAsync.Empty().Do((Action?)null, (Action?)null, r => completion = r) + .WaitCompletionAsync(); + await Assert.That(completion).IsNotNull(); + await Assert.That(completion!.Value.IsSuccess).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs index 3ca20dd6..c92b2511 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs @@ -10,11 +10,8 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests for transformation operators: Select, SelectMany, Scan, Do, Cast, OfType. -public class TransformationOperatorTests +public partial class TransformationOperatorTests { - /// Seconds a test waits for a notification before giving up. - private const int WaitTimeoutSeconds = 5; - /// Message thrown by an observer from its completion callback. private const string CompletionFailedMessage = "completion failed"; @@ -48,9 +45,6 @@ public class TransformationOperatorTests /// Mixed-type source in which no element is a string. private static readonly object[] OfTypeMisses = [1, 2, 3]; - /// Maximum time a test waits for a notification to arrive. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(WaitTimeoutSeconds); - /// Tests sync Select projects each element. /// A representing the asynchronous test operation. [Test] @@ -171,9 +165,7 @@ public async Task WhenSelectManyOuterCompletesBeforeInner_ThenWaitsForInnerCompl await Assert.That(completion.HasValue).IsFalse(); await inner.Complete(Result.Success); - await AsyncTestHelpers.WaitForConditionAsync( - () => completion.HasValue, - WaitTimeout); + await Assert.That(completion.HasValue).IsTrue(); await Assert.That(values).Contains(Two); await Assert.That(completion!.Value.IsSuccess).IsTrue(); @@ -202,9 +194,7 @@ public async Task WhenSelectManyInnerFails_ThenCompletesWithFailure() await outer.EmitNext(One); await inner.Complete(Result.Failure(expected)); - await AsyncTestHelpers.WaitForConditionAsync( - () => completion.HasValue, - WaitTimeout); + await Assert.That(completion.HasValue).IsTrue(); await Assert.That(completion!.Value.IsFailure).IsTrue(); await Assert.That(completion.Value.Exception).IsSameReferenceAs(expected); @@ -216,7 +206,7 @@ await AsyncTestHelpers.WaitForConditionAsync( public async Task WhenSelectManyInnerSubscribeThrows_ThenCompletesWithFailure() { InvalidOperationException expected = new("select-many-subscribe"); - Result? completion = null; + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); await using var subscription = await SignalAsync .Return(One) @@ -226,16 +216,13 @@ public async Task WhenSelectManyInnerSubscribeThrows_ThenCompletesWithFailure() null, result => { - completion = result; + IgnoredResult.Of(completed.TrySetResult(result)); return default; }); - await AsyncTestHelpers.WaitForConditionAsync( - () => completion.HasValue, - WaitTimeout); - - await Assert.That(completion!.Value.IsFailure).IsTrue(); - await Assert.That(completion.Value.Exception).IsSameReferenceAs(expected); + var completion = await completed.Task; + await Assert.That(completion.IsFailure).IsTrue(); + await Assert.That(completion.Exception).IsSameReferenceAs(expected); } /// Tests SelectMany null selector throws. @@ -282,205 +269,6 @@ public async Task WhenScanAsync_ThenEmitsRunningAccumulation() public void WhenScanNullAccumulator_ThenThrowsArgumentNull() => Assert.Throws(static () => SignalAsync.Return(1).Scan(0, (Func)null!)); - /// Tests Tap and Do null callback overloads return the original source. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenTapAndDoCallbacksAreNull_ThenReturnSource() - { - var source = SignalAsync.Return(1); - await Assert.That(source.Tap((Func?)null, null, null)) - .IsSameReferenceAs(source); - await Assert.That(source.Tap((Action)null!)).IsSameReferenceAs(source); - await Assert.That(source.Do((Action?)null, null, null)).IsSameReferenceAs(source); - } - - /// Exercises the sync-action Do<T>(Action<T>, Action<Exception>, Action<Result>) - /// overload's non-null-callback branches in SyncSideEffectObserver's OnNext / OnErrorResume / OnCompleted. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDoSyncWithAllCallbacks_ThenInvokesAndForwards() - { - const int ExpectedFirst = 7; - const int ExpectedSecond = 8; - List nextValues = []; - List errors = []; - List completions = []; - TaskCompletionSource errored = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - var source = SignalAsync.Create(static async (observer, ct) => - { - await observer.OnNextAsync(ExpectedFirst, ct); - await observer.OnErrorResumeAsync(new InvalidOperationException("resume"), ct); - await observer.OnNextAsync(ExpectedSecond, ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - await using var sub = await source.Do( - nextValues.Add, - exception => - { - errors.Add(exception); - _ = errored.TrySetResult(); - }, - result => - { - completions.Add(result); - _ = completed.TrySetResult(); - }).SubscribeAsync(static (_, _) => default); - await Task.WhenAll(errored.Task, completed.Task).WaitAsync(WaitTimeout); - await Assert.That(nextValues).IsCollectionEqualTo([ExpectedFirst, ExpectedSecond]); - await Assert.That(errors).Count().IsEqualTo(1); - await Assert.That(completions).Count().IsEqualTo(1); - } - - /// Tests Do with only a completion callback still forwards resumable errors. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDoSyncWithOnlyCompletionCallbackAndSourceEmitsErrorResume_ThenForwardsError() - { - List caughtErrors = []; - List completions = []; - TaskCompletionSource done = new(TaskCreationOptions.RunContinuationsAsynchronously); - var source = SignalAsync.Create(static async (observer, ct) => - { - await observer.OnErrorResumeAsync(new InvalidOperationException("sync completion-only"), ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - await using var sub = await source.Do((Action?)null, null, completions.Add) - .SubscribeAsync( - static (_, _) => default, - (ex, _) => - { - caughtErrors.Add(ex); - return default; - }, - _ => - { - IgnoredResult.Of(done.TrySetResult()); - return default; - }); - await done.Task.WaitAsync(WaitTimeout); - await Assert.That(caughtErrors).Count().IsEqualTo(1); - await Assert.That(completions).Count().IsEqualTo(1); - } - - /// Tests Tap with only an asynchronous completion callback invokes it. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenTapAsyncWithOnlyCompletionCallback_ThenInvokesCallback() - { - Result? completion = null; - await SignalAsync.Return(1).Tap((Func?)null, null, result => - { - completion = result; - return default; - }).WaitCompletionAsync(); - await Assert.That(completion).IsNotNull(); - await Assert.That(completion!.Value.IsSuccess).IsTrue(); - } - - /// Exercises the no-arg Do<T>() overload's null-callback branches on - /// the resumable-error and completion paths — pushes an OnErrorResumeAsync followed - /// by a successful completion through Do() with all callbacks null, hitting the - /// onErrorResume?.Invoke null arm and the onCompleted?.Invoke null arm. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDoWithNoCallbacksAndSourceEmitsErrorResume_ThenForwardsBoth() - { - Exception? caught = null; - var completed = false; - TaskCompletionSource errorTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource completionTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - var source = SignalAsync.Create(static async (observer, ct) => - { - await observer.OnErrorResumeAsync(new InvalidOperationException("resume"), ct); - await observer.OnCompletedAsync(Result.Success); - return DisposableAsync.Empty; - }); - await using var sub = await source.Do().SubscribeAsync( - static (_, _) => default, - (ex, _) => - { - caught = ex; - IgnoredResult.Of(errorTcs.TrySetResult()); - return default; - }, - _ => - { - completed = true; - IgnoredResult.Of(completionTcs.TrySetResult()); - return default; - }); - await Task.WhenAll(errorTcs.Task, completionTcs.Task).WaitAsync(WaitTimeout); - await Assert.That(caught).IsNotNull(); - await Assert.That(completed).IsTrue(); - } - - /// Exercises the no-arg Do<T>() overload — a pure pass-through that - /// constructs a DoSyncSignal with all callbacks set to null. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDoWithNoCallbacks_ThenPassesThroughValues() - { - const int ExpectedSecond = 2; - const int ExpectedThird = 3; - const int SourceValueCount = 3; - - var result = await SignalAsync.Range(1, SourceValueCount).Do().ToListAsync(); - await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); - } - - /// Tests sync Do invokes side effects. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDoSync_ThenInvokesSideEffects() - { - const int ExpectedSecond = 2; - const int ExpectedThird = 3; - const int SourceValueCount = 3; - - List sideEffects = []; - var result = await SignalAsync.Range(1, SourceValueCount).Do((x, _) => - { - sideEffects.Add(x); - return default; - }).ToListAsync(); - await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); - await Assert.That(sideEffects).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); - } - - /// Tests async Do invokes side effects. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDoAsync_ThenInvokesSideEffects() - { - const int ExpectedSecond = 2; - const int ExpectedThird = 3; - const int SourceValueCount = 3; - - List sideEffects = []; - var result = await SignalAsync.Range(1, SourceValueCount).Do(async (x, _) => - { - await Task.Yield(); - sideEffects.Add(x); - }).ToListAsync(); - await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); - await Assert.That(sideEffects).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]); - } - - /// Tests Do with completion handler invokes on completed. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenDoWithCompletionHandler_ThenInvokesOnCompleted() - { - Result? completion = null; - await SignalAsync.Empty().Do((Action?)null, (Action?)null, r => completion = r) - .WaitCompletionAsync(); - await Assert.That(completion).IsNotNull(); - await Assert.That(completion!.Value.IsSuccess).IsTrue(); - } - /// /// Verifies that Cast completes with a failure containing an /// when the source emits an element that cannot be cast to the target type. @@ -498,7 +286,7 @@ public async Task WhenCastIncompatibleType_ThenCompletesWithFailure() _ = tcs.TrySetResult(); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsFalse(); await Assert.That(completionResult.Value.Exception).IsTypeOf(); @@ -560,7 +348,7 @@ public async Task WhenPrependCancelledBeforeAllValues_ThenEarlyReturn() }, null); const int MinReceivedCount = 3; - await AsyncTestHelpers.WaitForConditionAsync(() => received.Count >= 3, WaitTimeout); + await Assert.That(received.Count >= 3).IsTrue(); await Assert.That(received.Count).IsGreaterThanOrEqualTo(MinReceivedCount); } @@ -587,7 +375,7 @@ public async Task WhenPrependSourceThrowsAndCompletionAlsoThrows_ThenRoutedToHan ValueTask.FromException(new ApplicationException("source error"))); var pipeline = source.Prepend(SentinelValue); await using var sub = await pipeline.SubscribeAsync(static (_, _) => default, null, _ => throw completionException); - var exception = await unhandled.WaitForAsync(CompletionFailedMessage, WaitTimeout); + var exception = await unhandled.WaitForAsync(CompletionFailedMessage); await Assert.That(exception).IsNotNull(); await Assert.That(exception!).IsTypeOf(); await Assert.That(exception!.Message).IsEqualTo(CompletionFailedMessage); @@ -631,7 +419,7 @@ public async Task WhenDoAsyncWithOnErrorResume_ThenInvokesCallbackAndForwardsErr IgnoredResult.Of(tcs.TrySetResult()); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(resumedErrors).Count().IsEqualTo(1); await Assert.That(resumedErrors[0].Message).IsEqualTo("test error"); await Assert.That(downstreamErrors).Count().IsEqualTo(1); @@ -659,7 +447,7 @@ public async Task WhenDoAsyncWithOnCompleted_ThenInvokesCallback() IgnoredResult.Of(tcs.TrySetResult()); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(capturedResult).IsNotNull(); await Assert.That(capturedResult!.Value.IsSuccess).IsTrue(); } @@ -696,7 +484,7 @@ public async Task WhenDoSyncWithOnErrorResume_ThenInvokesCallbackAndForwardsErro IgnoredResult.Of(tcs.TrySetResult()); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(resumedErrors).Count().IsEqualTo(1); await Assert.That(resumedErrors[0].Message).IsEqualTo("sync error"); await Assert.That(downstreamErrors).Count().IsEqualTo(1); @@ -752,7 +540,7 @@ public async Task WhenObserveOnSourceEmitsResumableError_ThenForwardsErrorDownst IgnoredResult.Of(tcs.TrySetResult()); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(receivedValues).IsCollectionEqualTo([1, ExpectedSecond]); await Assert.That(downstreamErrors).Count().IsEqualTo(1); await Assert.That(downstreamErrors[0]).IsTypeOf(); @@ -789,13 +577,7 @@ public async Task WhenPrependTokenCancelledBeforeIteration_ThenEmitsNoValues() await Assert.That(received.Count).IsLessThan(MaxReceivedCount); } - /// - /// Verifies that when the source observable passed to Prepend throws an exception during - /// subscription and the downstream observer's OnCompletedAsync handler also throws, - /// the secondary exception from the completion handler is routed to the - /// . - /// This covers the inner catch block that guards against completion handler failures. - /// + /// Verifies that a completion callback failure after Prepend subscription failure reaches the unhandled exception handler. /// A representing the asynchronous test operation. [Test] [SuppressMessage( @@ -814,12 +596,10 @@ public async Task WhenPrependSourceThrowsAndOnCompletedThrows_ThenSecondaryExcep var source = SignalAsync.Create(static (_, _) => ValueTask.FromException(new ApplicationException("source failure"))); - // Prepend a single value so the prepend loop completes, then SubscribeAsync on the - // throwing source triggers the catch path. The completion handler throws a second - // exception, which should be routed to the unhandled exception handler. + // Source subscription fails after the prepended value is delivered. var pipeline = source.Prepend(1); await using var sub = await pipeline.SubscribeAsync(static (_, _) => default, null, _ => throw secondaryException); - var exception = await unhandled.WaitForAsync(OnCompletedBlewUpMessage, WaitTimeout); + var exception = await unhandled.WaitForAsync(OnCompletedBlewUpMessage); await Assert.That(exception).IsNotNull(); await Assert.That(exception!).IsTypeOf(); await Assert.That(exception!.Message).IsEqualTo(OnCompletedBlewUpMessage); @@ -861,7 +641,7 @@ public async Task WhenYield_ThenForwardsCompletion() _ = tcs.TrySetResult(); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(capturedResult).IsNotNull(); await Assert.That(capturedResult!.Value.IsSuccess).IsTrue(); } @@ -885,7 +665,7 @@ public async Task WhenYieldSourceErrors_ThenForwardsError() _ = tcs.TrySetResult(); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(capturedResult).IsNotNull(); await Assert.That(capturedResult!.Value.IsSuccess).IsFalse(); } @@ -942,7 +722,7 @@ await group.SubscribeAsync( IgnoredResult.Of(tcs.TrySetResult()); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(groups).Count().IsEqualTo(ExpectedGroupCount); await Assert.That(groups[1]).IsCollectionEqualTo([1, OddSecond, OddThird]); await Assert.That(groups[0]).IsCollectionEqualTo([EvenFirst, EvenSecond, EvenThird]); @@ -975,20 +755,13 @@ static async (group, ct) => await group.SubscribeAsync(static (_, _) => default, IgnoredResult.Of(tcs.TrySetResult()); return default; }); - await AsyncTestHelpers.WaitForConditionAsync(() => tcs.Task.IsCompleted, WaitTimeout); + await tcs.Task; await Assert.That(downstreamErrors).Count().IsEqualTo(1); await Assert.That(downstreamErrors[0]).IsTypeOf(); await Assert.That(downstreamErrors[0].Message).IsEqualTo("group error"); } - /// - /// Verifies that when Prepend's source throws during subscription and the raw observer's - /// also throws, the secondary exception - /// from the completion handler is routed to the . - /// This exercises the inner catch block (lines 73-74) that guards against completion handler failures - /// by using a raw implementation that bypasses the - /// base class exception swallowing. - /// + /// Verifies that a raw observer's completion failure after Prepend subscription failure reaches the unhandled exception handler. /// A representing the asynchronous test operation. [Test] [SuppressMessage( @@ -1009,7 +782,7 @@ public async Task WhenPrependSourceThrowsAndRawObserverCompletionThrows_ThenRout var pipeline = source.Prepend(1); ThrowingOnCompletedWitness rawObserver = new(completionException); await using var sub = await pipeline.SubscribeAsync(rawObserver, CancellationToken.None); - var exception = await unhandled.WaitForAsync(RawObserverCompletionFailedMessage, WaitTimeout); + var exception = await unhandled.WaitForAsync(RawObserverCompletionFailedMessage); await Assert.That(exception).IsNotNull(); await Assert.That(exception!).IsTypeOf(); await Assert.That(exception!.Message).IsEqualTo(RawObserverCompletionFailedMessage); @@ -1100,7 +873,7 @@ public async Task WhenScanSyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("scan-sync-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } @@ -1122,16 +895,11 @@ public async Task WhenScanAsyncSourceErrorResume_ThenForwarded() }); InvalidOperationException expected = new("scan-async-error"); await signal.OnErrorResumeAsync(expected, CancellationToken.None); - await errorTcs.Task.WaitAsync(WaitTimeout); + await errorTcs.Task; await Assert.That(caught).IsSameReferenceAs(expected); } - /// - /// A raw implementation that throws a specified exception - /// from . Unlike , this - /// implementation does not catch exceptions internally, allowing callers to observe - /// the thrown exception directly. - /// + /// Throws directly from OnCompletedAsync without catching the exception. /// The type of elements received by the observer. /// The exception to throw when is called. private sealed class ThrowingOnCompletedWitness(Exception completionException) : IObserverAsync diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs index e0a117b3..4118c091 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs @@ -9,9 +9,6 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Captures unhandled async exceptions while restoring the previous process-wide handler on disposal. internal sealed class UnhandledExceptionCapture : IDisposable { - /// The polling interval used while waiting for delayed fire-and-forget callbacks. - private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(10); - /// Synchronizes access to the captured exception list. private readonly Lock _gate = new(); @@ -21,6 +18,9 @@ internal sealed class UnhandledExceptionCapture : IDisposable /// The handler that was active before this capture was installed. private readonly Action _previousHandler; + /// Signals the next captured exception. + private TaskCompletionSource _arrival = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Tracks whether the capture has already restored the previous handler. private int _disposed; @@ -37,45 +37,35 @@ public UnhandledExceptionCapture() /// Waits for an exception with the expected message. /// The expected exception message. - /// Maximum time to wait. - /// The matched exception, or if no match is observed. - internal Task WaitForAsync(string message, TimeSpan timeout) + /// The matching exception. + internal Task WaitForAsync(string message) { ArgumentNullException.ThrowIfNull(message); - return WaitForAsync(ex => ex.Message == message, timeout); + return WaitForAsync(ex => ex.Message == message); } /// Waits for a captured exception that satisfies the supplied predicate. /// The predicate used to find the expected exception. - /// Maximum time to wait. - /// The matched exception, or if no match is observed. - internal async Task WaitForAsync(Func predicate, TimeSpan timeout) + /// The matching exception. + internal async Task WaitForAsync(Func predicate) { ArgumentNullException.ThrowIfNull(predicate); - ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero); - - var deadline = TimeProvider.System.GetUtcNow().Add(timeout); - var match = Find(predicate); - - if (match is not null) - { - return match; - } - - using PeriodicTimer poll = new(PollInterval); - - while (TimeProvider.System.GetUtcNow() < deadline - && await poll.WaitForNextTickAsync(CancellationToken.None)) + while (true) { - match = Find(predicate); - - if (match is not null) + Task arrival; + lock (_gate) { - return match; + var match = _exceptions.FirstOrDefault(predicate); + if (match is not null) + { + return match; + } + + arrival = _arrival.Task; } - } - return Find(predicate); + await arrival; + } } /// Stores an exception routed through the temporary handler. @@ -85,17 +75,9 @@ private void Capture(Exception exception) lock (_gate) { _exceptions.Add(exception); - } - } - - /// Finds the first captured exception that matches the predicate. - /// The predicate used to find the expected exception. - /// The matched exception, or if none match. - private Exception? Find(Func predicate) - { - lock (_gate) - { - return _exceptions.FirstOrDefault(predicate); + var arrival = _arrival; + _arrival = new(TaskCreationOptions.RunContinuationsAsynchronously); + arrival.SetResult(); } } diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs index 7df6bbc4..62c51891 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs @@ -11,15 +11,6 @@ namespace ReactiveUI.Primitives.Avalonia.Reactive.Tests; /// Tests for against a pumped Avalonia headless dispatcher. public sealed class AvaloniaSchedulerTests { - /// Delay used to exercise the native dispatcher-timer path. - private static readonly TimeSpan DelayedDueTime = TimeSpan.FromMilliseconds(50); - - /// Delay used for work that is cancelled before its timer fires. - private static readonly TimeSpan CancellationDueTime = TimeSpan.FromMilliseconds(100); - - /// Delay for work that falls due after the cancelled work, on the same dispatcher and priority. - private static readonly TimeSpan FollowingDueTime = TimeSpan.FromMilliseconds(200); - /// Verifies constructor validation. /// A task representing the asynchronous operation. [Test] @@ -29,23 +20,22 @@ public async Task ConstructorRejectsNullDispatcher() => /// Verifies the singleton uses Avalonia's UI dispatcher and legacy background priority. /// A task representing the asynchronous operation. [Test] - public async Task InstanceUsesUiDispatcherAndBackgroundPriority() => - await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - await Assert.That(AvaloniaScheduler.Instance.Dispatcher).IsSameReferenceAs(Dispatcher.UIThread); - await Assert.That(AvaloniaScheduler.Instance.Priority).IsEqualTo(DispatcherPriority.Background); - await Assert.That(AvaloniaScheduler.Instance).IsSameReferenceAs(AvaloniaScheduler.Instance); - }, + public async Task InstanceUsesUiDispatcherAndBackgroundPriority() + { + var (scheduler, dispatcher) = await AvaloniaTestSession.Instance.Dispatch( + static () => (AvaloniaScheduler.Instance, Dispatcher.UIThread), CancellationToken.None); + await Assert.That(scheduler.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(scheduler.Priority).IsEqualTo(DispatcherPriority.Background); + await Assert.That(scheduler).IsSameReferenceAs(AvaloniaScheduler.Instance); + } + /// Verifies immediate scheduler work is posted to and executed on the selected dispatcher thread. /// A task representing the asynchronous operation. [Test] public async Task ImmediateScheduleExecutesOnDispatcherThread() { - // The session only awaits a dispatched delegate that returns a result, so the facts to assert come back - // out of the dispatch; an assertion left inside it past the first await is never observed. var (dispatcherThreadId, executionThreadId) = await AvaloniaTestSession.Instance.Dispatch( static async () => { @@ -64,10 +54,10 @@ static async () => await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); } - /// Verifies delayed scheduler work runs on a timer bound to the selected dispatcher. + /// Verifies due work runs on the selected dispatcher at the configured priority. /// A task representing the asynchronous operation. [Test] - public async Task DelayedScheduleExecutesOnDispatcherThread() + public async Task DueScheduleExecutesOnDispatcherThread() { var (dispatcherThreadId, executionThreadId, priority) = await AvaloniaTestSession.Instance.Dispatch( static async () => @@ -78,7 +68,7 @@ static async () => new(TaskCreationOptions.RunContinuationsAsynchronously); _ = scheduler.Schedule( - DelayedDueTime, + TimeSpan.Zero, () => completion.TrySetResult(Environment.CurrentManagedThreadId)); return ( @@ -92,10 +82,10 @@ static async () => await Assert.That(priority).IsEqualTo(DispatcherPriority.Normal); } - /// Verifies disposing delayed work stops its dispatcher timer before execution. + /// Verifies disposing queued work prevents execution. /// A task representing the asynchronous operation. [Test] - public async Task DelayedScheduleCanBeCancelled() + public async Task DueScheduleCanBeCancelled() { var executed = await AvaloniaTestSession.Instance.Dispatch( static async () => @@ -105,14 +95,11 @@ static async () => var executed = false; var disposable = scheduler.Schedule( - CancellationDueTime, + TimeSpan.Zero, () => executed = true); disposable.Dispose(); - // The following work is due after the cancelled work and shares its dispatcher and priority, so the - // dispatcher passes the cancelled due time first: the follower running means the cancelled action was - // skipped rather than merely still pending. - _ = scheduler.Schedule(FollowingDueTime, following.SetResult); + _ = scheduler.Schedule(TimeSpan.Zero, following.SetResult); await following.Task; return executed; diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs index b10961da..5bf305e3 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs @@ -2,7 +2,6 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics; using System.Runtime.CompilerServices; using Avalonia.Threading; using ReactiveUI.Primitives.Concurrency; @@ -12,12 +11,6 @@ namespace ReactiveUI.Primitives.Avalonia.Tests; /// Tests for against a pumped Avalonia headless dispatcher. public sealed class AvaloniaSchedulerTests { - /// Maximum time to wait for dispatcher work. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// Future scheduling delay in stopwatch ticks. - private static readonly long ScheduleDelayTicks = Stopwatch.Frequency / 20; - /// Verifies constructor validation. /// A task representing the asynchronous operation. [Test] @@ -27,74 +20,67 @@ public async Task ConstructorRejectsNullDispatcher() => /// Verifies the singleton uses Avalonia's UI dispatcher and legacy background priority. /// A task representing the asynchronous operation. [Test] - public async Task InstanceUsesUiDispatcherAndBackgroundPriority() => - await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - await Assert.That(AvaloniaScheduler.Instance.Dispatcher).IsSameReferenceAs(Dispatcher.UIThread); - await Assert.That(AvaloniaScheduler.Instance.Priority).IsEqualTo(DispatcherPriority.Background); - await Assert.That(AvaloniaScheduler.Instance).IsSameReferenceAs(AvaloniaScheduler.Instance); - await Assert.That(AvaloniaScheduler.Instance.Now).IsGreaterThan(DateTimeOffset.MinValue); - }, + public async Task InstanceUsesUiDispatcherAndBackgroundPriority() + { + var (scheduler, dispatcher) = await AvaloniaTestSession.Instance.Dispatch( + static () => (AvaloniaScheduler.Instance, Dispatcher.UIThread), CancellationToken.None); + await Assert.That(scheduler.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(scheduler.Priority).IsEqualTo(DispatcherPriority.Background); + await Assert.That(scheduler).IsSameReferenceAs(AvaloniaScheduler.Instance); + await Assert.That(scheduler.Now).IsGreaterThan(DateTimeOffset.MinValue); + } /// Verifies immediate work is posted to and executed on the selected dispatcher thread. /// A task representing the asynchronous operation. [Test] - public async Task ImmediateScheduleExecutesOnDispatcherThread() => - await AvaloniaTestSession.Instance.Dispatch( + public async Task ImmediateScheduleExecutesOnDispatcherThread() + { + var (dispatcherThreadId, executionThreadId) = await AvaloniaTestSession.Instance.Dispatch( static async () => { var dispatcherThreadId = Environment.CurrentManagedThreadId; AvaloniaScheduler scheduler = new(Dispatcher.UIThread); - TaskCompletionSource completion = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - scheduler.Schedule( - new DelegateWorkItem( - () => completion.TrySetResult(Environment.CurrentManagedThreadId))); - - var executionThreadId = await completion.Task.WaitAsync(WaitTimeout); - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + scheduler.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId))); + return (dispatcherThreadId, await completion.Task); }, CancellationToken.None); + await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + } - /// Verifies future work runs through a timer bound to the selected dispatcher. + /// Verifies due work executes on the selected dispatcher. /// A task representing the asynchronous operation. [Test] - public async Task DelayedScheduleExecutesOnDispatcherThread() => - await AvaloniaTestSession.Instance.Dispatch( + public async Task DueScheduleExecutesOnDispatcherThread() + { + var (dispatcherThreadId, executionThreadId, priority) = await AvaloniaTestSession.Instance.Dispatch( static async () => { var dispatcherThreadId = Environment.CurrentManagedThreadId; AvaloniaScheduler scheduler = new(Dispatcher.UIThread, DispatcherPriority.Normal); - TaskCompletionSource completion = - new(TaskCreationOptions.RunContinuationsAsynchronously); - + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); scheduler.Schedule( - new DelegateWorkItem( - () => completion.TrySetResult(Environment.CurrentManagedThreadId)), - scheduler.Timestamp + ScheduleDelayTicks); - - var executionThreadId = await completion.Task.WaitAsync(WaitTimeout); - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); - await Assert.That(scheduler.Priority).IsEqualTo(DispatcherPriority.Normal); + new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId)), + scheduler.Timestamp); + return (dispatcherThreadId, await completion.Task, scheduler.Priority); }, CancellationToken.None); + await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + await Assert.That(priority).IsEqualTo(DispatcherPriority.Normal); + } /// Verifies the sequencer validates both work-item overloads. /// A task representing the asynchronous operation. [Test] - public async Task ScheduleRejectsNullWorkItems() => - await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - AvaloniaScheduler scheduler = new(Dispatcher.UIThread); - - await Assert.That(() => scheduler.Schedule(null!)).ThrowsExactly(); - await Assert.That(() => scheduler.Schedule(null!, scheduler.Timestamp)).ThrowsExactly(); - }, + public async Task ScheduleRejectsNullWorkItems() + { + var scheduler = await AvaloniaTestSession.Instance.Dispatch( + static () => new AvaloniaScheduler(Dispatcher.UIThread), CancellationToken.None); + await Assert.That(() => scheduler.Schedule(null!)).ThrowsExactly(); + await Assert.That(() => scheduler.Schedule(null!, scheduler.Timestamp)).ThrowsExactly(); + } /// Work item backed by an action. /// Action to invoke. diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs index ac3ced4c..f6bf6180 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs @@ -17,9 +17,6 @@ public sealed class BlazorRendererSequencerTests /// Expected values produced by an immediate burst, used to verify FIFO order. private static readonly int[] ExpectedBurst = [1, 2, 3]; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies the constructor rejects a null renderer delegate. /// A representing the asynchronous operation. [Test] @@ -49,7 +46,7 @@ public async Task DispatcherSchedulerExecutesWork() _ = scheduler.Schedule(() => executed.TrySetResult(true)); - await Assert.That(await executed.Task.WaitAsync(GuardTimeout)).IsTrue(); + await Assert.That(await executed.Task).IsTrue(); } /// Verifies renderer-task faults reach the unhandled-exception handler instead of vanishing. @@ -64,7 +61,7 @@ public async Task FaultedRendererTaskRoutesToHandler() _ = scheduler.Schedule(static () => { }); - await Assert.That(await observed.Task.WaitAsync(GuardTimeout)).IsSameReferenceAs(fault); + await Assert.That(await observed.Task).IsSameReferenceAs(fault); } /// Verifies reactive component observation guards reject null inputs. @@ -154,7 +151,7 @@ public async Task ImmediateBurstExecutesInOrder() _ = scheduler.Schedule(() => values.Add(captured)); } - await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default); + await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } /// Fake renderer that runs marshalled work synchronously and records how often it was invoked. diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs index e708c43d..75b30157 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs @@ -2,17 +2,14 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Reactive.Concurrency; namespace ReactiveUI.Primitives.Blazor.Reactive.Tests; -/// -/// Tests for , driven through a test subclass that controls what its -/// dispatcher Post does: whether it accepts the drain, defers it, rejects it, or throws. This exercises -/// the immediate and delayed scheduling paths and the coalescing/drain bookkeeping without a real UI dispatcher. -/// +/// Tests dispatch acceptance, rejection, reentrancy, and delayed delivery using explicit drain callbacks. public sealed class CoalescingDispatchSchedulerTests { /// The scheduled state value the tests pass through the scheduler. @@ -21,9 +18,6 @@ public sealed class CoalescingDispatchSchedulerTests /// The number of posts expected once a second drain has been requested. private const int TwoPosts = 2; - /// Guard timeout for the timer-driven delayed dispatch. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// A short but non-zero due time that forces the delayed dispatch path. private static readonly TimeSpan ShortDelay = TimeSpan.FromMilliseconds(20); @@ -68,12 +62,13 @@ public async Task ZeroDueTimeSchedulesImmediately() await Assert.That(ran).IsTrue(); } - /// A positive due time defers the work through the default dispatcher timer path. + /// A positive due time defers work until the delay scheduler advances. /// A task representing the asynchronous operation. [Test] - public async Task PositiveDueTimeRunsThroughTheDispatcherTimerPath() + public async Task PositiveDueTimeRunsAfterTheDelaySchedulerAdvances() { - TestDispatchScheduler scheduler = new() { RunDrainInline = true }; + HistoricalScheduler clock = new(); + TestDispatchScheduler scheduler = new(clock) { RunDrainInline = true }; TaskCompletionSource ran = new(TaskCreationOptions.RunContinuationsAsynchronously); using var handle = scheduler.Schedule(State, ShortDelay, (_, _) => @@ -82,7 +77,9 @@ public async Task PositiveDueTimeRunsThroughTheDispatcherTimerPath() return Disposable.Empty; }); - await ran.Task.WaitAsync(GuardTimeout); + await Assert.That(ran.Task.IsCompleted).IsFalse(); + clock.AdvanceBy(ShortDelay); + await Assert.That(ran.Task.IsCompletedSuccessfully).IsTrue(); } /// A dispatcher that refuses the drain resets the coalescing gate so a later drain can be posted. @@ -165,12 +162,50 @@ public async Task WorkQueuedDuringADrainRepostsAnotherDrain() await Assert.That(scheduler.PostCount).IsEqualTo(TwoPosts); } + /// Verifies a nested drain may empty the queue before the outer batch finishes. + /// A task representing the asynchronous test operation. + [Test] + public async Task NestedDrainClaimsRemainingItemsExactlyOnce() + { + const int First = 1; + const int Second = 2; + const int Third = 3; + TestDispatchScheduler scheduler = new(); + List values = []; + _ = scheduler.Schedule(First, (owner, value) => + { + values.Add(value); + _ = owner.Schedule(Third, (_, next) => + { + values.Add(next); + return Disposable.Empty; + }); + return Disposable.Empty; + }); + _ = scheduler.Schedule(Second, (_, value) => + { + values.Add(value); + return Disposable.Empty; + }); + scheduler.RunDrainInline = true; + scheduler.RunPostedDrains(); + + await Assert.That(values).IsEquivalentTo([First, Second, Third], EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + /// A whose dispatcher post the test drives explicitly. private sealed class TestDispatchScheduler : CoalescingDispatchScheduler { /// Drains handed to that have not yet been run. private readonly Queue _postedDrains = new(); + /// Initializes a new instance of the class. + /// The delay scheduler. + public TestDispatchScheduler(IScheduler? clock = null) + : base(clock ?? new HistoricalScheduler()) + { + } + /// Gets the number of times the dispatcher was asked to post a drain. public int PostCount { get; private set; } diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs index b3d5457e..d3efac81 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs @@ -17,9 +17,6 @@ public sealed class BlazorRendererSequencerTests /// Expected values produced by an immediate burst, used to verify FIFO order. private static readonly int[] ExpectedBurst = [1, 2, 3]; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies the constructor rejects a null renderer delegate. /// A representing the asynchronous operation. [Test] @@ -49,7 +46,7 @@ public async Task DispatcherSequencerExecutesWork() sequencer.Schedule(new DelegateWorkItem(() => executed.TrySetResult(true))); - await Assert.That(await executed.Task.WaitAsync(GuardTimeout)).IsTrue(); + await Assert.That(await executed.Task).IsTrue(); } /// Verifies renderer-task faults reach the unhandled-exception handler instead of vanishing. @@ -64,7 +61,7 @@ public async Task FaultedRendererTaskRoutesToHandler() sequencer.Schedule(new DelegateWorkItem(static () => { })); - await Assert.That(await observed.Task.WaitAsync(GuardTimeout)).IsSameReferenceAs(fault); + await Assert.That(await observed.Task).IsSameReferenceAs(fault); } /// Verifies reactive component observation guards reject null inputs. @@ -154,7 +151,7 @@ public async Task ImmediateBurstExecutesInOrder() sequencer.Schedule(new DelegateWorkItem(() => values.Add(captured))); } - await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default); + await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } /// Work item that invokes a delegate when executed. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs index 934b6345..2307fcc2 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs @@ -7,13 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Tests; -/// -/// Verifies the seam that gives a System.Reactive scheduler the sequencer scheduling shape the shared Extensions -/// source calls. This leaf recompiles its own copy of the seam, so the Reactive leaf's tests do not cover it. -/// The file deliberately does not import System.Reactive.Concurrency: the Scheduler class in that -/// namespace carries extension methods with the same signatures, and importing it would make every call below -/// ambiguous. The scheduler types are therefore spelled out in full. -/// +/// Tests scheduling overloads. Fully qualified scheduler names avoid ambiguous System.Reactive extension methods. public class SequencerSchedulingExtensionsTests { /// State threaded through the closure-free stateful overloads. @@ -112,10 +106,7 @@ private sealed class CountingWorkItem : IWorkItem public void Execute() => ExecuteCount++; } - /// - /// Counts how often the scheduler ran a plain action. Holding the count here lets the action overloads be - /// handed as a method group, so the callback closes over nothing. - /// + /// Counts action invocations through a method-group callback. private sealed class RunCounter { /// Gets the number of times the scheduler ran the action. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs index d7880dcd..16fc6902 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs @@ -14,9 +14,6 @@ public class ContinuationTests /// Item pushed while the continuation is already locked; the barrier is expected to drop it. private const int DroppedItem = 2; - /// Guard timeout to keep barrier rendezvous from hanging the test run. - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - /// Verifies pushes the item downstream, locks, and completes once the phase is signalled by an unlock. /// A representing the asynchronous test operation. [Test] @@ -29,8 +26,8 @@ public async Task WhenLockValueTaskNotLocked_ThenEmitsAndCompletesOnUnlock() var lockTask = continuation.LockValueTask(1, observer); var unlockTask = continuation.UnLock(); - await lockTask.AsTask().WaitAsync(Timeout); - await unlockTask.WaitAsync(Timeout); + await lockTask.AsTask(); + await unlockTask; await Assert.That(values.Count).IsEqualTo(1); await Assert.That(values[0]).IsEqualTo(1); @@ -53,8 +50,8 @@ public async Task WhenLockValueTaskAlreadyLocked_ThenReturnsDefaultAndDropsItem( await second; var unlockTask = continuation.UnLock(); - await first.AsTask().WaitAsync(Timeout); - await unlockTask.WaitAsync(Timeout); + await first.AsTask(); + await unlockTask; await Assert.That(values.Count).IsEqualTo(1); await Assert.That(values[0]).IsEqualTo(1); @@ -72,8 +69,8 @@ public async Task WhenLockNotLocked_ThenEmitsAndCompletesOnUnlock() var lockTask = continuation.Lock(1, observer); var unlockTask = continuation.UnLock(); - await lockTask.WaitAsync(Timeout); - await unlockTask.WaitAsync(Timeout); + await lockTask; + await unlockTask; await Assert.That(values.Count).IsEqualTo(1); } @@ -93,8 +90,8 @@ public async Task WhenLockAlreadyLocked_ThenReturnsCompletedAndDropsItem() await Assert.That(second.IsCompleted).IsTrue(); var unlockTask = continuation.UnLock(); - await first.WaitAsync(Timeout); - await unlockTask.WaitAsync(Timeout); + await first; + await unlockTask; await Assert.That(values.Count).IsEqualTo(1); } @@ -106,7 +103,7 @@ public async Task WhenUnlockNotLocked_ThenCompletesImmediately() { using Continuation continuation = new(); - await continuation.UnLock().WaitAsync(Timeout); + await continuation.UnLock(); await Assert.That(continuation.CompletedPhases).IsEqualTo(0); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs index bc3eda1f..2c0d3058 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs @@ -4,9 +4,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests; -/// Multi-observer and post-terminal coverage for -/// — copy-on-write growth, mid-array unsubscribe, collapse back to single-observer, late -/// subscribers after error or completion, and dispose with active observers. +/// Tests multiple subscribers, unsubscribe, late subscribers, and disposal with active observers. public partial class CurrentValueSubjectTests { /// Initial value for multi-observer tests. @@ -229,12 +227,7 @@ public async Task WhenSubscriptionDisposedTwice_ThenIdempotent() await Assert.That(values).IsCollectionEqualTo([MultiInitialValue]); } - /// Verifies the multi-observer Unsubscribe path tolerates a stale dispose — - /// after a middle observer is detached from a 4-observer array, disposing its returned - /// subscription a second time hits the Array.IndexOf not-found early-return. - /// The 4-observer setup keeps _observers non-null after the first dispose (the - /// 2-observer setup collapses back to the single-observer fast path, which hits a - /// different short-circuit instead of the IndexOf path). + /// Verifies repeated unsubscribe leaves every remaining observer attached. /// A representing the asynchronous test operation. [Test] public async Task WhenMultiObserverDisposedTwice_ThenSecondDisposeIsNoOp() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/GlobalTestSetup.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/GlobalTestSetup.cs deleted file mode 100644 index af9aa9e9..00000000 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/GlobalTestSetup.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace ReactiveUI.Primitives.Extensions.Tests; - -/// Configures assembly-wide TUnit defaults via a hook so they are in place before any test executes. -internal static class GlobalTestSetup -{ - /// The worker-thread floor for the test process; see . - private const int MinPoolThreads = 32; - - /// The per-test time cap for the whole assembly; see . - private static readonly TimeSpan DefaultTestTimeout = TimeSpan.FromSeconds(60); - - /// - /// Caps every test at 60 seconds and raises the thread-pool worker floor before any test runs. - /// - /// The 60s cap: without a default, a single flaky test that hangs stalls the entire assembly (the - /// whole suite is serialised via [assembly: NotInParallel(nameof(UnhandledExceptionHandler))]) - /// and we lose the per-test failure signal — CI just reports the workflow-level timeout. 60s is far - /// above every legitimate test (slowest non-cancellation test is ~5s) so any future hang fails its - /// own test with a clear message instead of killing the whole run. - /// - /// - /// The thread-pool floor: several tests rendezvous two blocking work items on the default pool - /// (e.g. Continuation's Barrier.SignalAndWait, scheduled retries). A busy CI runner can - /// saturate the pool, which only hill-climbs new workers ~1/sec, so a rendezvous needing a second - /// thread can miss its guard window and time out. Raising the floor makes those threads available - /// immediately rather than starved behind unrelated work. - /// - /// - /// The TUnit test-discovery context exposing programmatic settings. - [Before(TestDiscovery)] - public static void ConfigureDefaults(BeforeTestDiscoveryContext context) - { - context.Settings.Timeouts.DefaultTestTimeout = DefaultTestTimeout; - - ThreadPool.GetMinThreads(out var workerThreads, out var completionPortThreads); - _ = ThreadPool.SetMinThreads( - Math.Max(workerThreads, MinPoolThreads), - Math.Max(completionPortThreads, MinPoolThreads)); - } -} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs index 4d9726d8..59c0b401 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs @@ -22,9 +22,6 @@ public class FirstAsValueTaskHelperTests /// Value used to verify subsequent values are ignored. private const int SecondValue = 11; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies the helper completes with the first value the source emits. /// A representing the asynchronous test operation. [Test] @@ -97,7 +94,7 @@ public async Task WhenAwaitedBeforeEmission_ThenCompletesOnLaterValue() Subject subject = new(); var pending = FirstAsValueTaskHelper.FirstAsValueTask(subject).AsTask(); subject.OnNext(FirstValue); - var result = await pending.WaitAsync(GuardTimeout); + var result = await pending; await Assert.That(result).IsEqualTo(FirstValue); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs index 1290bec0..505e9ce7 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests; -/// Coverage for the scheduler-routed overloads of the WaitFor* helpers on -/// . The non-scheduler overloads are -/// already covered in the companion file; these dispatch the subscribe call via a -/// scheduler before blocking. +/// Tests helpers that schedule subscription before waiting for values or termination. public partial class ObservableSubscriptionExtensionsTests { /// Sentinel value emitted by single-value scheduler tests. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs index 4403d08c..c84123ae 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Linq; -using System.Reactive.Subjects; namespace ReactiveUI.Primitives.Extensions.Tests; @@ -17,7 +16,7 @@ public partial class ObservableSubscriptionExtensionsTests private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); /// Deadline handed to the blocking helpers when the source never terminates, so the wait must expire. - private static readonly TimeSpan ExpiredTimeout = TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan ExpiredTimeout = TimeSpan.Zero; /// Verifies that SubscribeGetValue returns the last synchronously-emitted value. /// A representing the asynchronous test operation. @@ -102,9 +101,6 @@ public async Task WhenWaitForValueTimesOut_ThenTimeoutException() [Test] public async Task WhenWaitForCompletion_ThenReturnsAfterTerminal() { - // Helper returns void on success; the absence of TimeoutException after a synchronous - // completion is the contract under test. Use the value-returning sibling for the actual - // assertion so TUnit has a real check. Observable.Return(RxVoid.Default).WaitForCompletion(WaitTimeout); var subsequent = Observable.Return(RxVoid.Default).SubscribeGetValue(); await Assert.That(subsequent).IsEqualTo(RxVoid.Default); @@ -183,17 +179,8 @@ public async Task WhenWaitForErrorTimesOut_ThenTimeoutException() /// pass-through to the scheduler-aware core with default timeout. /// A representing the asynchronous test operation. [Test] - public async Task WhenWaitForCompletionUnitDefault_ThenReturnsOnCompletion() - { - Subject subject = new(); - var pump = Task.Run(() => - { - subject.OnNext(RxVoid.Default); - subject.OnCompleted(); - }); - subject.WaitForCompletion(); - await pump; - } + public async Task WhenWaitForCompletionUnitDefault_ThenReturnsOnCompletion() => + await Assert.That(static () => Observable.Return(RxVoid.Default).WaitForCompletion()).ThrowsNothing(); /// Exercises the no-op OnError body of ValueCaptureWitness — /// SubscribeGetValue on an erroring source still returns the last captured value @@ -227,10 +214,7 @@ public async Task WhenSubscribeGetErrorSourceCompletesWithValue_ThenReturnsNull( [Test] public async Task WhenWaitForValueSourceErrors_ThenGateSignalledAndDefaultReturned() { - Subject subject = new(); - var pump = Task.Run(() => subject.OnError(new InvalidOperationException("source-error"))); - var value = subject.WaitForValue(); - await pump; + var value = Observable.Throw(new InvalidOperationException("source-error")).WaitForValue(); await Assert.That(value).IsEqualTo(0); } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/BooleanReduceObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/BooleanReduceObservableTests.cs index 78a0c0ed..9044ff1d 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/BooleanReduceObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/BooleanReduceObservableTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the boolean-reduce operators backed by -/// BooleanReduceObservable — empty-source short-circuit, partial-value -/// suppression, target match/mismatch, error broadcast. +/// Tests boolean aggregation of empty and partially populated sources and error forwarding. public class BooleanReduceObservableTests { /// Synthetic error message attached to source errors. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchAndReturnWithFactoryObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchAndReturnWithFactoryObservableTests.cs index de105049..dcc81021 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchAndReturnWithFactoryObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchAndReturnWithFactoryObservableTests.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the factory overload of CatchAndReturn -/// backed by CatchAndReturnWithFactoryObservable<T, TException> — -/// matching exception path, non-matching exception passthrough, and -/// factory-error propagation. +/// Tests exception filtering and fallback factory error propagation. public class CatchAndReturnWithFactoryObservableTests { /// Synthetic error message attached to source errors. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchIgnoreObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchIgnoreObservableTests.cs index 0c167ab4..a1f360f6 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchIgnoreObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/CatchIgnoreObservableTests.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for CatchIgnore<TSource, TException> backed by -/// CatchIgnoreObservable<TSource, TException> — exception filtering and the -/// action-throws branch. +/// Tests exception filtering and recovery action failures. public class CatchIgnoreObservableTests { /// Verifies that CatchIgnore invokes the action and completes on a matching exception. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs index 44f414ec..5656de05 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs @@ -9,10 +9,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the Conflate operator backed by -/// ConflateObservable<T> — source-error path through the scheduler -/// marshaller, completion-while-throttled, fast-path interruption by a newer value, -/// and dispose mid-drain. +/// Tests conflated value replacement, scheduled termination, and disposal during delivery. public class ConflateObservableTests { /// Synthetic error message attached to source errors. @@ -70,10 +67,7 @@ public async Task WhenConflateNewerValueDuringThrottle_ThenReplacesPending() subject.OnNext(Third); scheduler.AdvanceBy(UpdatePeriodTicks * SettleMultiplier); - // Inside the throttle window: the first pending value is replaced by the newer one. - await Assert.That(results.Count).IsGreaterThanOrEqualTo(1); - await Assert.That(results).DoesNotContain(First); - await Assert.That(results).Contains(Second); + await Assert.That(results).IsCollectionEqualTo([Second, Third]); } /// Verifies that completion before any throttled emission flushes through. @@ -107,10 +101,7 @@ public async Task WhenConflateDisposedBeforeScheduledEmission_ThenSuppressed() sub.Dispose(); scheduler.AdvanceBy(UpdatePeriodTicks); - // Initial value may or may not have fired before disposal but no late emission must arrive. - var snapshot = results.Count; - scheduler.AdvanceBy(UpdatePeriodTicks); - await Assert.That(results.Count).IsEqualTo(snapshot); + await Assert.That(results).IsEmpty(); } /// Verifies that an OnNext arriving after the source has completed is silently dropped. @@ -175,10 +166,7 @@ public async Task WhenOnCompletedAfterError_ThenDropped() await Assert.That(completed).IsFalse(); } - /// Verifies 's - /// post-dispose Enqueue guard by constructing the sink directly, disposing it, and then - /// pushing notifications — exercising the defensive branch that is otherwise unreachable - /// through the front-door Conflate pipeline. + /// Verifies disposal suppresses queued notifications and callbacks already removed from the queue. /// A representing the asynchronous test operation. [Test] public async Task WhenSinkEnqueuedAfterDispose_ThenSilentlyDropped() @@ -193,15 +181,16 @@ public async Task WhenSinkEnqueuedAfterDispose_ThenSilentlyDropped() sink.OnNext(1); sink.OnError(new InvalidOperationException("late")); sink.OnCompleted(); + sink.ProcessNext(First); + sink.ForwardError(new InvalidOperationException("dequeued")); + sink.ForwardCompleted(); scheduler.AdvanceBy(UpdatePeriodTicks); await Assert.That(downstream.Values).IsEmpty(); await Assert.That(downstream.Error).IsNull(); await Assert.That(downstream.Completed).IsFalse(); } - /// Verifies 's - /// after-terminal guards on OnNext, OnError, and OnCompleted by constructing - /// the sink directly, terminating via OnError, and then pushing follow-up notifications. + /// Verifies notifications received after termination are ignored. /// A representing the asynchronous test operation. [Test] public async Task WhenSinkEventsAfterTerminated_ThenDropped() @@ -224,9 +213,7 @@ public async Task WhenSinkEventsAfterTerminated_ThenDropped() await Assert.That(downstream.Completed).IsFalse(); } - /// Recording observer used to verify direct-invocation tests of the conflate sink - /// and marshaller — does not race with a scheduler, so the assertion sees exactly the - /// notifications that were forwarded. + /// Records notifications delivered by explicitly invoked sink operations. /// The element type. private sealed class RecordingWitness : IObserver { diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs index 89cc4e27..0093facf 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Coverage for DetectStaleObservable's subscription-teardown branch — when the source -/// terminates synchronously during subscribe, the sink is already done by the time the upstream handle -/// is attached, so the attach disposes it instead of recording it. +/// Tests disposal of an upstream subscription returned after synchronous source termination. public class DetectStaleObservableTests { /// Staleness window used by the tests. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs index 7cf94151..ac8333f0 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs @@ -6,8 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Coverage for DoOnDisposeObservable — the dispose action fires exactly once even when -/// the subscription is disposed multiple times, and the upstream is torn down before the action runs. +/// Tests that the disposal action runs once after upstream disposal. public class DoOnDisposeObservableTests { /// Verifies the dispose action fires once and the upstream is detached on first dispose, and a diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs index b69dfef7..7b5c1702 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DropIfBusyObservableTests.cs @@ -2,11 +2,13 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for DropIfBusyObservable<T>. +/// Tests handler completion and error delivery around source termination. public class DropIfBusyObservableTests { /// Verifies a handler completion after source completion does not emit the value. @@ -14,22 +16,14 @@ public class DropIfBusyObservableTests [Test] public async Task WhenHandlerCompletesAfterSourceDone_ThenValueDropped() { - Subject subject = new(); - - // The release gate completes its continuations inline, so the handler's tail runs here. - TaskCompletionSource release = new(); - TaskCompletionSource handlerResumed = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); List values = []; var completed = false; - using var sub = subject.DropIfBusy(async _ => - { - await release.Task.ConfigureAwait(false); - handlerResumed.SetResult(); - }).Subscribe(values.Add, () => completed = true); - subject.OnNext(1); - subject.OnCompleted(); + using DropIfBusyObservable.DropIfBusySink sink = new(Observer.Create(values.Add, () => completed = true), _ => new ValueTask(release.Task)); + var processing = sink.OnNextAsync(1); + sink.OnCompleted(); release.SetResult(); - await handlerResumed.Task; + await processing; await Assert.That(values).IsEmpty(); await Assert.That(completed).IsTrue(); } @@ -39,24 +33,24 @@ public async Task WhenHandlerCompletesAfterSourceDone_ThenValueDropped() [Test] public async Task WhenHandlerThrowsAfterSourceDone_ThenErrorDropped() { - Subject subject = new(); - - // The release gate completes its continuations inline, so the handler throws here. - TaskCompletionSource release = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new("late-handler"); Exception? caught = null; var completed = false; - using var sub = subject.DropIfBusy(async _ => - { - await release.Task.ConfigureAwait(false); - throw expected; - }).Subscribe( - static _ => { }, - ex => caught = ex, - () => completed = true); - subject.OnNext(1); - subject.OnCompleted(); + using DropIfBusyObservable.DropIfBusySink sink = new( + Observer.Create( + static _ => { }, + ex => caught = ex, + () => completed = true), + async _ => + { + await release.Task.ConfigureAwait(false); + throw expected; + }); + var processing = sink.OnNextAsync(1); + sink.OnCompleted(); release.SetResult(); + await processing; await Assert.That(caught).IsNull(); await Assert.That(completed).IsTrue(); } @@ -82,8 +76,8 @@ public async Task WhenSourceErrorsBeforeDone_ThenForwardsError() public async Task WhenHandlerThrowsBeforeDone_ThenForwardsError() { Subject subject = new(); - TaskCompletionSource release = new(); - TaskCompletionSource error = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource error = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new("handler"); using var sub = subject.DropIfBusy(async _ => { diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs index ad7f72aa..e0bd4599 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs @@ -8,10 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Coverage for the asynchronous-projection path of -/// FirstMatchFromCandidates backed by FirstMatchFromCandidatesObservable -/// — empty candidate list, async-projection match, async-projection no-match falls back, -/// async-projection error skips, and dispose during the async walk. +/// Tests asynchronous candidate matching, fallback, errors, and cancellation. public class FirstMatchFromCandidatesAsyncPathTests { /// Fallback value emitted when no candidate matches. @@ -29,9 +26,6 @@ public class FirstMatchFromCandidatesAsyncPathTests /// Candidate key whose projection emits the match value. private const string HitKey = "hit"; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that an empty candidate list emits the fallback and completes. /// A representing the asynchronous test operation. [Test] @@ -67,7 +61,7 @@ public async Task WhenAsyncProjectionMatches_ThenEmitsMatch() Fallback).Subscribe(results.Add, () => completed.TrySetResult(true)); emissionGate.OnNext(HitKey); emissionGate.OnCompleted(); - var done = await completed.Task.WaitAsync(GuardTimeout); + var done = await completed.Task; await Assert.That(done).IsTrue(); await Assert.That(results).IsCollectionEqualTo([HitKey]); } @@ -90,7 +84,7 @@ public async Task WhenAsyncProjectionNeverMatches_ThenFallback() Fallback).Subscribe(results.Add, () => completed.TrySetResult(true)); subject.OnNext("nope"); subject.OnCompleted(); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; await Assert.That(results).IsCollectionEqualTo([Fallback]); } @@ -114,7 +108,7 @@ public async Task WhenAsyncProjectionErrors_ThenSkipsToNextCandidate() badSubject.OnError(new InvalidOperationException("bad failed")); goodSubject.OnNext("good"); goodSubject.OnCompleted(); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; await Assert.That(results).IsCollectionEqualTo(["good"]); } @@ -132,9 +126,6 @@ public async Task WhenDisposedDuringAsyncWalk_ThenStops() .Subscribe(results.Add, () => completed = true); sub.Dispose(); - // Second dispose hits the Interlocked.Exchange null-loser branch in AsyncSink.Dispose - // — the first call swapped in null and disposed the previous subscription, so the - // second call sees null and the `?.Dispose()` no-op fires. sub.Dispose(); firstSubject.OnNext("late"); firstSubject.OnCompleted(); @@ -220,7 +211,7 @@ public async Task WhenAsyncCandidateEmitsAfterMatch_ThenDroppedByDoneGuard() .FirstMatchFromCandidates(_ => subject, static raw => raw, static value => value == HitKey, Fallback) .Subscribe(results.Add, () => completed.TrySetResult()); subject.OnNext(HitKey); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; subject.OnNext("ignored-late"); subject.OnError(new InvalidOperationException("ignored-late")); subject.OnCompleted(); @@ -234,10 +225,7 @@ public async Task WhenAsyncCandidateEmitsAfterMatch_ThenDroppedByDoneGuard() [Test] public async Task WhenAsyncSinkWalkHitsSyncErroringCandidate_ThenLoopingGuardSkipsAhead() { - // First candidate's projection is async (never completes during Subscribe), forcing - // TrySyncLoop to hand off to AsyncSink. Second candidate's projection synchronously - // errors during AsyncSink.TryNext's loop iteration, hitting AsyncSink.OnError with - // _looping == true. + // The second candidate errors synchronously after the first asynchronous candidate completes. string[] keys = [AsyncKey, SyncErrorKey, HitKey]; Subject asyncSubject = new(); List results = []; @@ -253,12 +241,9 @@ public async Task WhenAsyncSinkWalkHitsSyncErroringCandidate_ThenLoopingGuardSki static value => value == HitKey, Fallback).Subscribe(results.Add, () => completed.TrySetResult()); - // Complete the async subject — AsyncSink.OnCompleted runs (outside TryNext, so _looping - // is false), which invokes TryNext. The next iteration projects SyncErrorKey whose - // SyncErroringObservable.Subscribe calls observer.OnError synchronously, re-entering - // AsyncSink.OnError while _looping is still true — hitting the looping-guard return. + // Completing the first candidate starts the synchronously failing candidate. asyncSubject.OnCompleted(); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; await Assert.That(results).IsCollectionEqualTo([HitKey]); } @@ -284,7 +269,7 @@ public async Task WhenAsyncSinkWalkHitsSyncCompletingCandidate_ThenLoopingGuardS static value => value == HitKey, Fallback).Subscribe(results.Add, () => completed.TrySetResult()); asyncSubject.OnCompleted(); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; await Assert.That(results).IsCollectionEqualTo([HitKey]); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs index e03af031..fd4d54ec 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs @@ -30,9 +30,6 @@ public class ForEachObservableTests /// Scheduler-delivered sentinel. private const int ScheduledThirty = 30; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that a null inner enumerable is ignored and subsequent batches continue flowing. /// A representing the asynchronous test operation. [Test] @@ -56,8 +53,10 @@ public async Task WhenForEachWithScheduler_ThenDeliversAllValues() var source = batches.ToObservable(); TaskCompletionSource> done = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; - using var sub = source.ForEach(Sequencer.Default).Subscribe(results.Add, () => done.TrySetResult(results)); - var output = await done.Task.WaitAsync(GuardTimeout); + VirtualClock scheduler = new(); + using var sub = source.ForEach(scheduler).Subscribe(results.Add, () => done.TrySetResult(results)); + scheduler.Start(); + var output = await done.Task; await Assert.That(output).IsCollectionEqualTo([ScheduledTen, ScheduledTwenty, ScheduledThirty]); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs index b13df8e2..9c87ba86 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for Heartbeat backed by -/// HeartbeatObservable<T> — heartbeat-on-quiet, error/completion -/// forwarding, and post-terminal timer suppression. +/// Tests heartbeats during source inactivity and cancellation after termination. public class HeartbeatObservableTests { /// Heartbeat period for the scheduler-driven tests. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs index 90e8af85..74161728 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the GetMin / GetMax operators -/// backed by MinMaxObservable<T> — partial-source suppression, -/// max/min selection over multiple updates, and source-error propagation. +/// Tests extrema across source updates, missing initial values, and source errors. public class MinMaxObservableTests { /// Synthetic error message attached to source errors. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs index 311cb6c0..bc56ce74 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs @@ -7,17 +7,12 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the reactive-condition ObserveOnIf overload -/// backed by ObserveOnIfObservable<T> — condition switching, error forwarding, -/// and completion forwarding. +/// Tests scheduler selection from condition changes and terminal notification forwarding. public class ObserveOnIfObservableTests { /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that values dispatch on the false-scheduler before any condition arrives. /// A representing the asynchronous test operation. [Test] @@ -32,7 +27,7 @@ public async Task WhenObserveOnIfNoCondition_ThenUsesFalseScheduler() using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler) .Subscribe(v => emitted.TrySetResult(v)); source.OnNext(Value); - var v2 = await emitted.Task.WaitAsync(GuardTimeout); + var v2 = await emitted.Task; await Assert.That(v2).IsEqualTo(Value); await Assert.That(falseScheduler.ScheduleCount).IsGreaterThanOrEqualTo(1); await Assert.That(trueScheduler.ScheduleCount).IsEqualTo(0); @@ -53,7 +48,7 @@ public async Task WhenObserveOnIfConditionTrue_ThenUsesTrueScheduler() .Subscribe(v => emitted.TrySetResult(v)); condition.OnNext(true); source.OnNext(Value); - var v2 = await emitted.Task.WaitAsync(GuardTimeout); + var v2 = await emitted.Task; await Assert.That(v2).IsEqualTo(Value); await Assert.That(trueScheduler.ScheduleCount).IsGreaterThanOrEqualTo(1); } @@ -67,7 +62,7 @@ public async Task WhenObserveOnIfSourceErrors_ThenForwardsError() Subject condition = new(); Exception? caught = null; InvalidOperationException expected = new(SourceErrorMessage); - using var sub = source.ObserveOnIf(condition, TaskPoolSequencer.Default, Sequencer.Immediate).Subscribe( + using var sub = source.ObserveOnIf(condition, new RecordingScheduler(), Sequencer.Immediate).Subscribe( static _ => { }, ex => caught = ex); source.OnError(expected); @@ -82,7 +77,7 @@ public async Task WhenObserveOnIfSourceCompletes_ThenForwardsCompletion() Subject source = new(); Subject condition = new(); var completed = false; - using var sub = source.ObserveOnIf(condition, TaskPoolSequencer.Default, Sequencer.Immediate).Subscribe( + using var sub = source.ObserveOnIf(condition, new RecordingScheduler(), Sequencer.Immediate).Subscribe( static _ => { }, () => completed = true); source.OnCompleted(); @@ -180,14 +175,14 @@ public async Task WhenObserveOnIfConditionDuplicate_ThenSilentlyShortCircuits() // Sanity: subsequent value still routes through the true-scheduler (the duplicate did // not corrupt the captured state). - await Assert.That(values.Count).IsLessThanOrEqualTo(1); + await Assert.That(values).IsCollectionEqualTo([1]); } /// Sequencer that delegates to the default thread-pool sequencer but records each scheduled work item. private sealed class RecordingScheduler : ISequencer { /// Backing scheduler used to actually dispatch work. - private readonly TaskPoolSequencer _inner = TaskPoolSequencer.Default; + private readonly ImmediateSequencer _inner = Sequencer.Immediate; /// Gets the number of recorded schedule calls. public int ScheduleCount { get; private set; } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs index 79111667..e00a6485 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs @@ -9,9 +9,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Coverage for ObserveOnObservable (reached via ObserveOnSafe) — the -/// immediate-scheduler passthrough, the queue-and-drain marshaller's value / error / completion -/// forwarding, dispose teardown, and the attach-after-terminated branch of the shared drain state. +/// Tests immediate delivery, scheduled notifications, and subscription disposal. public class ObserveOnObservableTests { /// Synthetic error message attached to source errors. @@ -146,9 +144,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Scheduler that runs scheduled work synchronously on the calling thread, so a drain pass - /// executes inline during the schedule call. Distinct instance from - /// so the operator's immediate-scheduler passthrough does not apply. + /// Runs work inline while retaining the operator's queued delivery path. private sealed class InlineScheduler : ISequencer { /// diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs index 47b1c064..bbb0d56c 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs @@ -11,12 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Covers the consistent if (_done) return; after-terminal guards on the -/// remaining sync operators that share the pattern but lacked dedicated coverage — -/// RetryWithDelay, OnErrorRetry, TakeUntilInclusive, SwitchIfEmpty, -/// ThrottleOnScheduler, BufferUntilIdle, ObserveOnIf. Each test drives a -/// through one terminal event, then pushes additional -/// notifications past the terminal to verify the guard silently drops them. +/// Tests suppression of notifications after source termination. public class OperatorAfterTerminalGuardTests { /// Retry delay handed to the retry operators under test. @@ -230,10 +225,7 @@ public async Task WhenDropIfBusyEventsAfterCompleted_ThenDropped() [Test] public async Task WhenWhileDownstreamDisposesInsideOnNext_ThenIterateGuardSkipsNextPredicate() { - // The scheduler indirection lets us defer the first iteration to after Subscribe has - // returned (so the SingleAssignmentDisposable can capture the subscription), then run - // the inner iterations synchronously enough that the OnNext-side dispose hits before - // the second Iterate evaluates the predicate. + // Capture the subscription before running the iteration that disposes it. VirtualClock scheduler = new(); var actionCalls = 0; SingleAssignmentDisposable sub = new(); @@ -463,7 +455,7 @@ public async Task WhenScheduledSourceActionThrows_ThenForwardsError() public async Task WhenSubscribeSynchronousOmitsErrorAndCompletedCallbacks_ThenNullPathsTaken() { Subject subject = new(); - TaskCompletionSource processed = new(); + TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = subject.SubscribeSynchronous(value => { _ = processed.TrySetResult(); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs index 3d844f6e..6c259191 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.MultiSubscriber.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Coverage for the multi-subscriber and idempotent-dispose paths of -/// Partition backed by PartitionObservable<T> — three observers on -/// one side, mid-array removal, and double-dispose of a side subscription. +/// Tests multiple partition subscribers and idempotent subscriber removal. public partial class PartitionObservableTests { /// Verifies that three observers on the same side each receive every matching value. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.cs index 73d1cf1d..5d7ae27d 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PartitionObservableTests.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for Partition backed by -/// PartitionObservable<T> — both-sides routing, single-side disposal, -/// error broadcast, completion broadcast, and re-subscription after both sides drop. +/// Tests partition routing, shared source lifetime, and terminal notifications. public partial class PartitionObservableTests { /// Synthetic error message attached to source errors. @@ -167,8 +165,7 @@ public async Task WhenSubscriptionDisposedAfterParentSinkTornDown_ThenNoOp() var first = evens.Subscribe(static _ => { }); first.Dispose(); - // Subscribe again to create a fresh sink, then dispose the OLD disposable a second time - // (which now finds _sink == null because the prior tear-down already nulled it). + // Repeated disposal must leave the replacement subscription active. using var second = evens.Subscribe(static _ => { }); first.Dispose(); List results = []; diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PropertyChangedObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PropertyChangedObservableTests.cs index cbde3952..705cfd3b 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PropertyChangedObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/PropertyChangedObservableTests.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for ToPropertyObservable backed by -/// PropertyChangedObservable<T, TProperty> — initial-value emission, -/// matching-name forwarding, unmatched-name filtering, getter-throws forwarding, -/// and dispose detaches the handler. +/// Tests initial property values, name filtering, getter errors, and handler removal. public class PropertyChangedObservableTests { /// Initial property value. @@ -114,16 +111,13 @@ public async Task WhenPropertyEventFiresAfterDispose_ThenHandlerGuardSkipsForwar sub.Dispose(); - // Even after Dispose, the retaining owner still references the handler — invoking the - // event delivers to it, but the handler observes _disposed != 0 and returns early. + // The owner retains the handler after disposal; further events must be ignored. owner.Raise(); await Assert.That(results).IsCollectionEqualTo([0]); } - /// INPC owner that retains every handler ever attached and exposes a manual - /// Raise so a test can fire the PropertyChanged event after the subscription that - /// added the handler has already been disposed. + /// Retains removed event handlers so tests can invoke callbacks after disposal. private sealed class RetainingObservableOwner : INotifyPropertyChanged { /// The retained handler list. @@ -139,9 +133,7 @@ public event PropertyChangedEventHandler? PropertyChanged } } - /// Gets the observed property. This fixture never writes it, so every read yields zero — - /// what is under test is the notification, not the value. It stays an instance auto-property - /// because that is what the ToPropertyObservable expression tree resolves against. + /// Gets the observed property, whose value remains zero. public int Value { get; } /// Invokes the retained handler with a PropertyChanged event for . diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs index 7e8f80df..92986704 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RetryAndThrottleAndFactoryOperatorTests.cs @@ -8,12 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for several small synchronous operators: -/// WhereSelect, FromArray, RetryWithDelay, -/// RetryForeverWithDelay, ThrottleOnScheduler, -/// ThrottleDistinct (sync), SubscribeAndComplete error path, -/// Schedule with side-effect and transform overloads, -/// ToReadOnlyBehavior, and Pairwise after-error path. +/// Tests retry, throttle, scheduling, and source factory termination. public class RetryAndThrottleAndFactoryOperatorTests { /// Synthetic error message attached to source errors. @@ -135,14 +130,14 @@ public async Task WhenFromArrayEnumerationThrows_ThenForwardsError() await Assert.That(caught).IsSameReferenceAs(expected); } - /// Verifies that RetryWithDelay retries the configured number of times with - /// a zero delay (so retries happen synchronously on the default scheduler). + /// Verifies zero-delay retries exhaust the configured count and forward the source error. /// A representing the asynchronous test operation. [Test] public async Task WhenRetryWithDelayAlwaysFails_ThenRetriesThenErrors() { const int RetryCount = 3; var attempts = 0; + Exception? caught = null; InvalidOperationException expected = new("attempt failed"); var source = Observable.Create(o => { @@ -152,10 +147,10 @@ public async Task WhenRetryWithDelayAlwaysFails_ThenRetriesThenErrors() }); using var sub = source.RetryWithDelay(RetryCount, static _ => TimeSpan.Zero).Subscribe( static _ => { }, - static _ => { }); + error => caught = error); - // Initial attempt + RetryCount retries = RetryCount+1 total invocations. - await Assert.That(attempts).IsGreaterThan(1); + await Assert.That(attempts).IsEqualTo(RetryCount + 1); + await Assert.That(caught).IsSameReferenceAs(expected); } /// Verifies that RetryForeverWithDelay keeps retrying after failures. @@ -181,7 +176,7 @@ public async Task WhenRetryForeverWithDelay_ThenKeepsRetrying() }); List results = []; using var sub = source.RetryForeverWithDelay(TimeSpan.Zero).Subscribe(results.Add); - await Assert.That(attempts).IsGreaterThanOrEqualTo(Value3); + await Assert.That(attempts).IsEqualTo(Value3); await Assert.That(results).IsCollectionEqualTo([Value1]); } @@ -217,7 +212,7 @@ public async Task WhenThrottleOnSchedulerSourceErrors_ThenForwardsError() await Assert.That(caught).IsSameReferenceAs(expected); } - /// Verifies that ThrottleDistinct (sync overload, no scheduler) emits distinct values respecting the throttle window. + /// Verifies the default-scheduler overload forwards source errors. /// A representing the asynchronous test operation. [Test] public async Task WhenThrottleDistinctSyncDefaultScheduler_ThenForwardsSourceError() @@ -246,7 +241,7 @@ public async Task WhenThrottleDistinctSyncWithScheduler_ThenSuppressesUpstreamDu subject.OnNext(Value1); subject.OnNext(Value1); scheduler.AdvanceBy(AdvancePastWindowTicks); - await Assert.That(results.Count).IsLessThanOrEqualTo(1); + await Assert.That(results).IsCollectionEqualTo([Value1]); } /// Verifies that ToReadOnlyBehavior returns a paired observable / observer that diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RunAllObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RunAllObservableTests.cs index 22d2541d..8b6f5c6c 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RunAllObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/RunAllObservableTests.cs @@ -2,22 +2,47 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using System.Reactive.Linq; using System.Reactive.Subjects; using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for RunAll backed by RunAllObservable — -/// empty-list short-circuit, sequential walk through synchronous and asynchronous -/// sources, error propagation, and disposal mid-walk. +/// Tests sequential source execution, empty input, errors, and disposal during execution. public class RunAllObservableTests { /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); + /// Verifies disposal prevents a pending run from reporting completion. + /// A task representing the asynchronous test operation. + [Test] + public async Task RunCompletionAfterDisposalIsIgnored() + { + List values = []; + var completed = false; + RunAllObservable.Sink sink = new(Observer.Create(values.Add, () => completed = true), []); + sink.Dispose(); + sink.CompleteRun(); + await Assert.That(values).IsEmpty(); + await Assert.That(completed).IsFalse(); + } + + /// Verifies repeated run completion emits exactly one value and terminal notification. + /// A task representing the asynchronous test operation. + [Test] + public async Task RunCompletionIsDeliveredOnce() + { + List values = []; + var completed = 0; + using RunAllObservable.Sink sink = new(Observer.Create(values.Add, () => completed++), []); + sink.CompleteRun(); + sink.CompleteRun(); + await Assert.That(values).IsCollectionEqualTo([RxVoid.Default]); + await Assert.That(completed).IsEqualTo(1); + } /// Verifies that an empty list emits and completes immediately. /// A representing the asynchronous test operation. @@ -75,7 +100,7 @@ public async Task WhenRunAllAsyncSources_ThenWalksSequentially() subjectA.OnCompleted(); await Assert.That(subjectB.HasObservers).IsTrue(); subjectB.OnCompleted(); - var done = await completed.Task.WaitAsync(GuardTimeout); + var done = await completed.Task; await Assert.That(done).IsTrue(); } @@ -111,16 +136,12 @@ public async Task WhenRunAllDisposedMidWalk_ThenStops() () => completed = true); sub.Dispose(); - // Second dispose hits the Interlocked.Exchange null-loser branch in Sink.Dispose — - // the first call swapped in null and disposed the previous subscription, so the - // second call sees null and the `?.Dispose()` no-op fires. sub.Dispose(); subjectA.OnCompleted(); await Assert.That(completed).IsFalse(); await Assert.That(subjectB.HasObservers).IsFalse(); } - /// Returns [0, 1, …, count-1] for collection-equality assertions. /// Verifies that OnNext, OnError and a duplicate OnCompleted /// arriving from a candidate after RunAll has already completed are silently dropped. /// A representing the asynchronous test operation. @@ -144,10 +165,7 @@ public async Task WhenEventsAfterCompleted_ThenDropped() await Assert.That(caught).IsNull(); } - /// Exercises RunAll.RunNext's post-loop _done guard — a source - /// that synchronously errors during Subscribe sets _done = true inline, - /// the while (!_done ...) loop bails, and the post-loop check returns without - /// emitting RxVoid.Default. + /// Verifies synchronous source errors suppress the final value and completion. /// A representing the asynchronous test operation. [Test] public async Task WhenRunAllSourceSyncErrors_ThenPostLoopDoneGuardSuppressesFinalEmit() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SampleLatestObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SampleLatestObservableTests.cs index 7ff67b30..bbbbc400 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SampleLatestObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SampleLatestObservableTests.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for SampleLatest backed by -/// SampleLatestObservable<T> — trigger before any source value, -/// source completion, source error, trigger error, and trigger completion not -/// terminating downstream. +/// Tests sampling without a source value and source or trigger termination. public class SampleLatestObservableTests { /// Synthetic error message attached to source errors. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs index 2d033876..478f6ca4 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.Terminal.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Terminal-notification coverage for ScanWithInitial — source error, source completion, and post-terminal value ignore. +/// Tests source termination and suppression of subsequent values. public partial class ScanWithInitialTests { /// Synthetic error message attached to source errors. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs index 5d0f1bdb..3b61b004 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScanWithInitialTests.cs @@ -106,11 +106,12 @@ public async Task OnCompletedDuringEmission_CompletesOnceAndStopsAccumulating() x => { results.Add(x); - if (results.Count == EmissionsBeforeCompletion) + if (results.Count != EmissionsBeforeCompletion) { - // Re-enter the sink with the terminal notification from inside its own emission. - source.OnCompleted(); + return; } + + source.OnCompleted(); }, static _ => { }, () => completedCount++)) diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs index 26d58d8e..e5b9c0e2 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledAndDebounceSyncOperatorTests.cs @@ -8,12 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage batch for several small synchronous operators: -/// DetectStale, BufferUntilIdle, DebounceImmediate, -/// DebounceUntil, Schedule (value and source overloads), -/// LatestOrDefault, Pairwise, WaitUntil, -/// SwitchIfEmpty. Tests focus on the terminal/error/disposal branches -/// that the existing happy-path tests don't already cover. +/// Tests scheduled value delivery, debounce termination, and disposal. public class ScheduledAndDebounceSyncOperatorTests { /// Synthetic error message attached to source errors. @@ -170,10 +165,8 @@ public async Task WhenDebounceUntilConditionFalse_ThenDebounced() await Assert.That(results).IsCollectionEqualTo([Value1]); } - /// Verifies that Schedule(this T value, TimeSpan, ISequencer) emits the value after the delay. + /// Verifies scheduled delivery emits a value without completion. /// A representing the asynchronous test operation. - /// The operator preserves the original Observable.Create-based semantics — - /// the scheduled callback emits OnNext only; OnCompleted is not signalled. [Test] public async Task WhenScheduleValueWithDelay_ThenEmitsAfterDelay() { diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs index aa225c02..6cbcbb47 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ScheduledSourceObservableTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Direct coverage for ScheduledSourceObservable<T>'s -/// no-op terminal handlers and the EmitState action/transform catch block — -/// branches the happy-path scheduler tests don't reach. +/// Tests ignored terminal handlers and scheduled action or transform failures. public class ScheduledSourceObservableTests { /// Sentinel value used by the emission tests. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs index e1539579..73733bf3 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncConcurrentObservableTests.cs @@ -2,13 +2,13 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for SelectAsyncConcurrent backed by -/// SelectAsyncConcurrentObservable<TSource, TResult> — error forwarding, -/// disposal mid-flight, and deferred completion while in-flight selectors finish. +/// Tests concurrent projection completion, errors, and disposal during projection. public class SelectAsyncConcurrentObservableTests { /// Synthetic error message attached to a failing selector. @@ -30,12 +30,11 @@ public async Task WhenSelectAsyncConcurrentSelectorThrows_ThenForwardsError() { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource faulted = new(); + TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new(SelectorErrorMessage); - using var sub = subject.SelectAsyncConcurrent(_ => Task.FromException(expected), MaxConcurrencyTwo) - .Subscribe( - static _ => { }, - ex => faulted.TrySetResult(ex)); + using var sub = subject.SelectAsyncConcurrent(_ => Task.FromException(expected), MaxConcurrencyTwo).Subscribe( + static _ => { }, + ex => faulted.TrySetResult(ex)); subject.OnNext(TriggerValue); var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); @@ -62,31 +61,20 @@ public async Task WhenSelectAsyncConcurrentSourceErrors_ThenForwardsError() public async Task WhenSelectAsyncConcurrentDisposedMidFlight_ThenSuppressesEmissionAndCompletion() { const int TriggerValue = 1; - Subject subject = new(); - - // The gate completes its continuations inline, so releasing it runs the selector's tail here. - TaskCompletionSource gate = new(); - TaskCompletionSource selectorResumed = new(); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; var completed = false; - var sub = subject.SelectAsyncConcurrent( - async x => - { - await gate.Task.ConfigureAwait(false); - _ = selectorResumed.TrySetResult(true); - return x; - }, - MaxConcurrencyTwo).Subscribe(results.Add, () => completed = true); - subject.OnNext(TriggerValue); - subject.OnCompleted(); - sub.Dispose(); - gate.SetResult(true); - await selectorResumed.Task; + SelectAsyncConcurrentObservable.SelectAsyncConcurrentSink sink = new(Observer.Create(results.Add, () => completed = true), _ => gate.Task, MaxConcurrencyTwo); + var processing = sink.OnNextAsync(TriggerValue); + sink.Dispose(); + gate.SetResult(TriggerValue); + await processing; + sink.OnCompleted(); await Assert.That(results).IsEmpty(); await Assert.That(completed).IsFalse(); } - /// Verifies that completion arriving while selectors are still in flight is forwarded after all selectors finish. + /// Verifies completion waits for every active projection. /// A representing the asynchronous test operation. [Test] public async Task WhenSelectAsyncConcurrentCompletesWithInFlight_ThenDeferredCompletion() @@ -94,9 +82,9 @@ public async Task WhenSelectAsyncConcurrentCompletesWithInFlight_ThenDeferredCom const int First = 1; const int Second = 2; Subject subject = new(); - TaskCompletionSource gate = new(); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; - TaskCompletionSource completed = new(); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = subject.SelectAsyncConcurrent( async x => { @@ -107,16 +95,13 @@ public async Task WhenSelectAsyncConcurrentCompletesWithInFlight_ThenDeferredCom subject.OnNext(First); subject.OnNext(Second); subject.OnCompleted(); - - // Both selectors are parked on the gate, so nothing can have emitted or completed. await Assert.That(completed.Task.IsCompleted).IsFalse(); await Assert.That(results).IsEmpty(); gate.SetResult(true); var done = await completed.Task; await Assert.That(done).IsTrue(); - // Downstream OnNext from this operator is serialized inside the sink's lock, so the - // list is safely populated by the time completion fires. Order is concurrent so sort. + // Delivery is serialized, but concurrent projections may finish in either order. int[] sorted = [.. results]; Array.Sort(sorted); await Assert.That(sorted).IsCollectionEqualTo([First, Second]); @@ -132,8 +117,7 @@ public async Task WhenEventsAfterCompleted_ThenDropped() List values = []; Exception? caught = null; var completedCount = 0; - using var sub = source.SelectAsyncConcurrent(Task.FromResult, 1) - .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + using var sub = source.SelectAsyncConcurrent(Task.FromResult, 1).Subscribe(values.Add, ex => caught = ex, () => completedCount++); source.Observer.OnCompleted(); source.Observer.OnNext(1); source.Observer.OnError(new InvalidOperationException("late")); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs index d6b1fa38..513fcf4c 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectAsyncSequentialObservableTests.cs @@ -2,13 +2,13 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for SelectAsyncSequential backed by -/// SelectAsyncSequentialObservable<TSource, TResult> — error forwarding, -/// disposal mid-flight, and completion while an in-flight selector is running. +/// Tests sequential projection completion, errors, and disposal during projection. public class SelectAsyncSequentialObservableTests { /// Synthetic error message attached to a failing selector. @@ -25,12 +25,10 @@ public async Task WhenSelectAsyncSequentialSelectorThrows_ThenForwardsErrorAndSt const int First = 1; const int Second = 2; Subject subject = new(); - TaskCompletionSource faulted = new(); + TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; InvalidOperationException expected = new(SelectorErrorMessage); - using var sub = subject - .SelectAsyncSequential(x => x == First ? Task.FromException(expected) : Task.FromResult(x)) - .Subscribe(results.Add, ex => faulted.TrySetResult(ex)); + using var sub = subject.SelectAsyncSequential(x => x == First ? Task.FromException(expected) : Task.FromResult(x)).Subscribe(results.Add, ex => faulted.TrySetResult(ex)); subject.OnNext(First); subject.OnNext(Second); var caught = await faulted.Task; @@ -59,24 +57,15 @@ public async Task WhenSelectAsyncSequentialSourceErrors_ThenForwardsError() public async Task WhenSelectAsyncSequentialDisposedMidFlight_ThenSuppressesEmissionAndCompletion() { const int TriggerValue = 1; - Subject subject = new(); - - // The gate completes its continuations inline, so releasing it runs the selector's tail here. - TaskCompletionSource gate = new(); - TaskCompletionSource selectorResumed = new(); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; var completed = false; - var sub = subject.SelectAsyncSequential(async x => - { - await gate.Task.ConfigureAwait(false); - _ = selectorResumed.TrySetResult(true); - return x; - }).Subscribe(results.Add, () => completed = true); - subject.OnNext(TriggerValue); - subject.OnCompleted(); - sub.Dispose(); - gate.SetResult(true); - await selectorResumed.Task; + SelectAsyncSequentialObservable.SelectAsyncSequentialSink sink = new(Observer.Create(results.Add, () => completed = true), _ => gate.Task); + var processing = sink.OnNextAsync(TriggerValue); + sink.Dispose(); + gate.SetResult(TriggerValue); + await processing; + sink.OnCompleted(); await Assert.That(results).IsEmpty(); await Assert.That(completed).IsFalse(); } @@ -88,9 +77,9 @@ public async Task WhenSelectAsyncSequentialCompletesWhileProcessing_ThenDeferred { const int Value = 42; Subject subject = new(); - TaskCompletionSource gate = new(); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; - TaskCompletionSource completed = new(); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = subject.SelectAsyncSequential(async x => { await gate.Task.ConfigureAwait(false); @@ -98,8 +87,6 @@ public async Task WhenSelectAsyncSequentialCompletesWhileProcessing_ThenDeferred }).Subscribe(results.Add, () => completed.TrySetResult(true)); subject.OnNext(Value); subject.OnCompleted(); - - // The selector is parked on the gate, so nothing can have emitted or completed. await Assert.That(completed.Task.IsCompleted).IsFalse(); await Assert.That(results).IsEmpty(); gate.SetResult(true); @@ -119,8 +106,7 @@ public async Task WhenEventsAfterTerminated_ThenDropped() List values = []; Exception? caught = null; var completedCount = 0; - using var sub = source.SelectAsyncSequential(Task.FromResult) - .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + using var sub = source.SelectAsyncSequential(Task.FromResult).Subscribe(values.Add, ex => caught = ex, () => completedCount++); source.Observer.OnCompleted(); source.Observer.OnError(new InvalidOperationException("late")); source.Observer.OnCompleted(); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs index 5f6c67db..ca47b167 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectLatestAsyncObservableTests.cs @@ -2,13 +2,13 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for SelectLatestAsync backed by -/// SelectLatestAsyncObservable<TSource, TResult> — error forwarding, -/// disposal mid-flight, stale-id drop path and completion-after-in-flight. +/// Tests latest projection delivery, errors, and disposal during projection. public class SelectLatestAsyncObservableTests { /// Synthetic error message attached to a failing selector. @@ -17,9 +17,6 @@ public class SelectLatestAsyncObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Multiplier applied by the gated selector whose result is expected never to be delivered. - private const int SuppressedProjectionMultiplier = 2; - /// Multiplier applied inside the projection selector. private const int ProjectionMultiplier = 10; @@ -30,7 +27,7 @@ public async Task WhenSelectLatestAsyncSelectorThrows_ThenForwardsError() { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource faulted = new(); + TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new(SelectorErrorMessage); using var sub = subject.SelectLatestAsync(_ => Task.FromException(expected)).Subscribe( static _ => { }, @@ -61,28 +58,42 @@ public async Task WhenSelectLatestAsyncSourceErrors_ThenForwardsError() public async Task WhenSelectLatestAsyncDisposedMidFlight_ThenSuppressesEmissionAndCompletion() { const int TriggerValue = 1; - Subject subject = new(); - - // The gate completes its continuations inline, so releasing it runs the selector's tail here. - TaskCompletionSource gate = new(); - TaskCompletionSource selectorResumed = new(); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; var completed = false; - var sub = subject.SelectLatestAsync(async x => - { - await gate.Task.ConfigureAwait(false); - _ = selectorResumed.TrySetResult(true); - return x * SuppressedProjectionMultiplier; - }).Subscribe(results.Add, () => completed = true); - subject.OnNext(TriggerValue); - subject.OnCompleted(); - sub.Dispose(); - gate.SetResult(true); - await selectorResumed.Task; + SelectLatestAsyncObservable.SelectLatestAsyncSink sink = new(Observer.Create(results.Add, () => completed = true), _ => gate.Task); + var processing = sink.OnNextAsync(TriggerValue); + sink.Dispose(); + gate.SetResult(TriggerValue); + await processing; + sink.OnCompleted(); + sink.SignalCompleted(); await Assert.That(results).IsEmpty(); await Assert.That(completed).IsFalse(); } + /// Verifies source completion waits for the latest projection and is delivered once. + /// A task representing the asynchronous test operation. + [Test] + public async Task SourceCompletionWaitsForLatestProjection() + { + const int Value = 1; + TaskCompletionSource projection = new(TaskCreationOptions.RunContinuationsAsynchronously); + List values = []; + var completions = 0; + using SelectLatestAsyncObservable.SelectLatestAsyncSink sink = new( + Observer.Create(values.Add, () => completions++), + _ => projection.Task); + var processing = sink.OnNextAsync(Value); + sink.OnCompleted(); + await Assert.That(completions).IsZero(); + projection.SetResult(Value); + await processing; + sink.SignalCompleted(); + await Assert.That(values).IsCollectionEqualTo([Value]); + await Assert.That(completions).IsEqualTo(1); + } + /// Verifies that a newer value supersedes a slower in-flight projection, so only the latest result is emitted. /// A representing the asynchronous test operation. [Test] @@ -90,44 +101,29 @@ public async Task WhenSelectLatestAsyncNewerArrives_ThenOlderResultDropped() { const int Slow = 1; const int Fast = 2; - Subject subject = new(); - - // The gate completes its continuations inline, so releasing it runs the stale projection's tail here. - TaskCompletionSource slowGate = new(); - TaskCompletionSource slowResumed = new(); + TaskCompletionSource slowGate = new(TaskCreationOptions.RunContinuationsAsynchronously); List results = []; - TaskCompletionSource completed = new(); - using var sub = subject.SelectLatestAsync(async x => - { - if (x == Slow) - { - await slowGate.Task.ConfigureAwait(false); - _ = slowResumed.TrySetResult(true); - } - - return x * ProjectionMultiplier; - }).Subscribe(results.Add, () => completed.TrySetResult(true)); - subject.OnNext(Slow); - subject.OnNext(Fast); - - // The Fast projection is ungated, so its result is already delivered. + var completed = false; + using SelectLatestAsyncObservable.SelectLatestAsyncSink sink = new( + Observer.Create(results.Add, () => completed = true), + value => value == Slow ? slowGate.Task : Task.FromResult(value * ProjectionMultiplier)); + var slow = sink.OnNextAsync(Slow); + await sink.OnNextAsync(Fast); await Assert.That(results).IsCollectionEqualTo([Fast * ProjectionMultiplier]); - slowGate.SetResult(true); - await slowResumed.Task; - subject.OnCompleted(); - await completed.Task; - - // Only the latest (Fast) projection's result should appear. + slowGate.SetResult(Slow * ProjectionMultiplier); + await slow; + sink.OnCompleted(); + await Assert.That(completed).IsTrue(); await Assert.That(results).IsCollectionEqualTo([Fast * ProjectionMultiplier]); } - /// Verifies that source completion before any value still completes downstream. + /// Verifies an empty source completes downstream. /// A representing the asynchronous test operation. [Test] public async Task WhenSelectLatestAsyncSourceCompletesWithNoValues_ThenForwardsCompletion() { Subject subject = new(); - TaskCompletionSource completed = new(); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = subject.SelectLatestAsync(Task.FromResult).Subscribe( static _ => { }, () => completed.TrySetResult(true)); @@ -146,13 +142,12 @@ public async Task WhenEventsAfterCompleted_ThenDropped() List values = []; Exception? caught = null; var completedCount = 0; - using var sub = source.SelectLatestAsync(Task.FromResult) - .Subscribe(values.Add, ex => caught = ex, () => completedCount++); + using var sub = source.SelectLatestAsync(Task.FromResult).Subscribe(values.Add, ex => caught = ex, () => completedCount++); source.Observer.OnCompleted(); source.Observer.OnNext(1); source.Observer.OnError(new InvalidOperationException("late")); source.Observer.OnCompleted(); - await Assert.That(completedCount).IsLessThanOrEqualTo(1); + await Assert.That(completedCount).IsEqualTo(1); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectManyThenObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectManyThenObservableTests.cs index 3b46d163..3deef9d8 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectManyThenObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SelectManyThenObservableTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for SelectManyThen backed by -/// SelectManyThenObservable<TSource, TMid, TResult> — two-stage projection, -/// first/second projection throws, source error/completion, and inner-observable errors. +/// Tests two-stage projection and propagation of source, projection, and inner errors. public class SelectManyThenObservableTests { /// Synthetic error messages. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs index 1f67e9a6..5a57b33a 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SimpleSyncOperatorTests.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for several small synchronous operators — Shuffle, Filter (regex), TrySelect. +/// Tests shuffle, regex filtering, and conditional projection. public partial class SimpleSyncOperatorTests { /// Synthetic error message attached to source errors. @@ -31,9 +31,6 @@ public partial class SimpleSyncOperatorTests /// Shuffle test sentinel. private const int Shuffle5 = 5; - /// Length of the pathological input that drives the regex into catastrophic backtracking. - private const int PathologicalInputLength = 100; - /// Divisor the TrySelect projection uses to keep even values and drop the odd ones. private const int EvenDivisor = 2; @@ -146,15 +143,15 @@ public async Task WhenFilterNullInput_ThenIgnored() [Test] public async Task WhenFilterRegexThrows_ThenForwardsError() { - // A regex with a 1-microsecond timeout against pathological input should throw. - var regex = PathologicalCatastrophicBacktrack(); + RegexMatchTimeoutException expected = new("matching failed"); + ThrowingRegex regex = new(expected); Subject subject = new(); Exception? caught = null; using var sub = subject.Filter(regex).Subscribe( static _ => { }, ex => caught = ex); - subject.OnNext($"{new string('a', PathologicalInputLength)}!"); - await Assert.That(caught).IsNotNull(); + subject.OnNext(Apple); + await Assert.That(caught).IsSameReferenceAs(expected); } /// Verifies that TrySelect drops null projections and forwards non-nulls. @@ -202,10 +199,37 @@ public async Task WhenTrySelectSourceCompletes_ThenForwardsCompletion() [GeneratedRegex("^a")] private static partial Regex StartsWithA(); - /// Compiled regex with catastrophic backtracking and a 1-tick timeout — - /// guaranteed to throw on pathological input. - /// Used to exercise the error-forwarding branch of Filter. - /// A compile-time generated instance with a 1-tick match timeout. - [GeneratedRegex("(a+)+$", RegexOptions.None, 1)] - private static partial Regex PathologicalCatastrophicBacktrack(); + /// A regex whose matching engine throws the supplied error. + private sealed class ThrowingRegex : Regex + { + /// Initializes a new instance of the class. + /// The matching error. + public ThrowingRegex(Exception error) + { + factory = new ThrowingRunnerFactory(error); + capsize = 1; + } + + /// Creates matching engines that fail with the supplied error. + /// The matching error. + private sealed class ThrowingRunnerFactory(Exception error) : RegexRunnerFactory + { + /// + protected override RegexRunner CreateInstance() => new ThrowingRunner(error); + } + + /// Throws when matching begins. + /// The matching error. + private sealed class ThrowingRunner(Exception error) : RegexRunner + { + /// + protected override void Go() => throw error; + + /// + protected override bool FindFirstChar() => true; + + /// + protected override void InitTrackCount() => runtrackcount = 1; + } + } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartActionObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartActionObservableTests.cs index be24edc4..261d3972 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartActionObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartActionObservableTests.cs @@ -6,17 +6,12 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the action-form Start operator backed by -/// StartActionObservable — synchronous inline path, scheduler dispatch, -/// and action-throws forwarding. +/// Tests inline and scheduled actions and exception propagation. public class StartActionObservableTests { /// Synthetic error message attached to action failures. private const string ActionFailedMessage = "action failed"; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that Start with a null scheduler runs synchronously and completes. /// A representing the asynchronous test operation. [Test] @@ -39,13 +34,15 @@ public async Task WhenStartActionInline_ThenRunsAndCompletes() [Test] public async Task WhenStartActionWithScheduler_ThenRunsViaScheduler() { + VirtualClock scheduler = new(); TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); var ran = false; - using var sub = ReactiveExtensions.Start(() => ran = true, TaskPoolSequencer.Default) + using var sub = ReactiveExtensions.Start(() => ran = true, scheduler) .Subscribe(static _ => { }, () => completed.TrySetResult(true)); + scheduler.Start(); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; await Assert.That(ran).IsTrue(); } @@ -68,13 +65,15 @@ public async Task WhenStartActionThrowsInline_ThenForwardsError() [Test] public async Task WhenStartActionThrowsScheduled_ThenForwardsError() { + VirtualClock scheduler = new(); TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new(ActionFailedMessage); - using var sub = ReactiveExtensions.Start(() => throw expected, TaskPoolSequencer.Default) + using var sub = ReactiveExtensions.Start(() => throw expected, scheduler) .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + scheduler.Start(); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartFuncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartFuncObservableTests.cs index c717e35f..0d9d4ffc 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartFuncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/StartFuncObservableTests.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the Start(Func{TResult}, ISequencer?) overload -/// backed by StartFuncObservable<TResult> — paths missed by the happy-path tests -/// (inline vs scheduler dispatch and function-throws on both paths). +/// Tests inline and scheduled function results and exception propagation. public class StartFuncObservableTests { /// Result returned by the Start tests. @@ -17,9 +15,6 @@ public class StartFuncObservableTests /// Message attached to a thrown Start function. private const string FunctionFailedMessage = "function failed"; - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that the inline (null-scheduler) overload runs the function, emits the result and completes. /// A representing the asynchronous test operation. [Test] @@ -40,13 +35,15 @@ public async Task WhenStartFuncInline_ThenEmitsResultAndCompletes() [Test] public async Task WhenStartFuncOnScheduler_ThenRunsOnSchedulerAndCompletes() { + VirtualClock scheduler = new(); List results = []; TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var sub = ReactiveExtensions.Start(static () => StartResult, Sequencer.Default) + using var sub = ReactiveExtensions.Start(static () => StartResult, scheduler) .Subscribe(results.Add, () => completed.TrySetResult()); + scheduler.Start(); - await completed.Task.WaitAsync(GuardTimeout); + await completed.Task; await Assert.That(results).IsCollectionEqualTo([StartResult]); } @@ -69,13 +66,15 @@ public async Task WhenStartFuncThrows_ThenForwardsError() [Test] public async Task WhenStartFuncOnSchedulerThrows_ThenForwardsError() { + VirtualClock scheduler = new(); TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new(FunctionFailedMessage); - using var sub = ReactiveExtensions.Start((Func)(() => throw expected), Sequencer.Default) + using var sub = ReactiveExtensions.Start((Func)(() => throw expected), scheduler) .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + scheduler.Start(); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs index 9b269275..9e32be19 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs @@ -3,12 +3,11 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Subjects; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the SubscribeSynchronous / SubscribeAsync -/// overloads backed by SubscribeAsyncObservable<T> — sequential handler invocation, -/// handler-throws forwards via onError, completion-while-processing defers, disposal stops queue. +/// Tests sequential handlers, deferred completion, error forwarding, and disposal. public class SubscribeAsyncObservableTests { /// Synthetic error message attached to handler failures. @@ -26,7 +25,7 @@ public async Task WhenSubscribeAsyncProcessesValues_ThenInOrder() const int Second = 2; Subject subject = new(); List results = []; - TaskCompletionSource completed = new(); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = subject.SubscribeSynchronous( x => { @@ -50,7 +49,7 @@ public async Task WhenSubscribeAsyncHandlerThrows_ThenForwardsToOnError() { const int TriggerValue = 1; Subject subject = new(); - TaskCompletionSource faulted = new(); + TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new(HandlerFailedMessage); using var sub = subject.SubscribeSynchronous(_ => ValueTask.FromException(expected), ex => faulted.TrySetResult(ex)); @@ -98,8 +97,8 @@ public async Task WhenSubscribeAsyncCompletesWhileProcessing_ThenDeferredComplet Subject subject = new(); // The gate completes its continuations inline, so releasing it drains the pump before control returns. - TaskCompletionSource gate = new(); - TaskCompletionSource completed = new(); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = subject.SubscribeSynchronous( async _ => await gate.Task.ConfigureAwait(false), () => completed.TrySetResult(true)); @@ -120,20 +119,13 @@ public async Task WhenSubscribeAsyncCompletesWhileProcessingWithoutCallback_Then { const int Value = 7; Subject subject = new(); - TaskCompletionSource gate = new(); - TaskCompletionSource handled = new(); - using var sub = subject.SubscribeSynchronous(async value => - { - await gate.Task.ConfigureAwait(false); - _ = handled.TrySetResult(true); - }); - subject.OnNext(Value); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + using SubscribeAsyncObservable subscription = new(subject, _ => new ValueTask(gate.Task), null, null); + var processing = subscription.OnNextAsync(Value); subject.OnCompleted(); - - // Releasing the gate resumes the handler inline, so the null-completion path runs here. - gate.SetResult(true); - var done = await handled.Task; - await Assert.That(done).IsTrue(); + gate.SetResult(); + await processing; + await Assert.That(processing.IsCompletedSuccessfully).IsTrue(); } /// Verifies disposal during an in-flight handler suppresses deferred terminal callbacks. @@ -143,26 +135,23 @@ public async Task WhenSubscribeAsyncDisposedDuringInFlight_ThenSuppressesComplet { const int Value = 7; Subject subject = new(); - TaskCompletionSource gate = new(); - TaskCompletionSource handlerStarted = new(); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); Exception? caught = null; var completedCount = 0; - var sub = subject.SubscribeSynchronous( - async value => + SubscribeAsyncObservable subscription = new( + subject, + async _ => { - _ = handlerStarted.TrySetResult(true); await gate.Task.ConfigureAwait(false); throw new InvalidOperationException(HandlerFailedMessage); }, ex => caught = ex, () => completedCount++); - subject.OnNext(Value); - await handlerStarted.Task; + var processing = subscription.OnNextAsync(Value); subject.OnCompleted(); - sub.Dispose(); - - // Releasing the gate lets the handler throw inline, so the suppressed terminal paths run here. - gate.SetResult(true); + subscription.Dispose(); + gate.SetResult(); + await processing; await Assert.That(caught).IsNull(); await Assert.That(completedCount).IsEqualTo(0); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncDirectSource.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncDirectSource.cs index c413d994..362cffab 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncDirectSource.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncDirectSource.cs @@ -2,18 +2,11 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reactive.Subjects; using ReactiveUI.Primitives.Disposables; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// -/// Synchronous test source that hands its observer back to the test so the test can -/// invoke OnNext / OnError / OnCompleted directly — including -/// sequences that would otherwise block (emit-after-complete, -/// double-terminal). Subscriptions return a no-op disposable so external dispose does -/// not detach the observer. -/// +/// Retains its observer for direct notification after disposal or termination. /// The element type. internal sealed class SyncDirectSource : IObservable { diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncOperatorErrorForwardingTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncOperatorErrorForwardingTests.cs index 1a087cef..5355e668 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncOperatorErrorForwardingTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SyncOperatorErrorForwardingTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Direct coverage for the trivial OnError forwarders on a cluster of small -/// synchronous operators. Each method is a one-liner that hands a source error straight to -/// the downstream observer; the existing happy-path tests never exercised the error branch. +/// Tests error propagation through synchronous operators. public class SyncOperatorErrorForwardingTests { /// Synthetic error message used by every forwarder test. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs index 376fd170..76df3e6b 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs @@ -2,12 +2,39 @@ // 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.Primitives.Extensions.Operators; + namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Tests for SynchronizeAsyncObservable — covers the after-terminal guards -/// on the sink that only fire when the upstream pushes events past its own completion. +/// Tests synchronization acknowledgements and notification handling after termination. public class SynchronizeAsyncObservableTests { + /// Verifies disposal completes an acknowledgement published after the disposal step. + /// A task representing the asynchronous test operation. + [Test] + public async Task AcknowledgementPublishedAfterDisposalCompletes() + { + SynchronizeAsyncObservable.SynchronizeAsyncSink.SyncSignal signal = new(); + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + signal.Dispose(); + signal.CompleteIfDisposedRaced(completion); + signal.CompleteIfDisposedRaced(completion); + await Assert.That(completion.Task.IsCompletedSuccessfully).IsTrue(); + } + + /// Verifies a live acknowledgement remains pending until disposal. + /// A task representing the asynchronous test operation. + [Test] + public async Task LiveAcknowledgementWaitsForDisposal() + { + SynchronizeAsyncObservable.SynchronizeAsyncSink.SyncSignal signal = new(); + var acknowledgement = signal.WaitForDisposeAsync(); + await Assert.That(acknowledgement.IsCompleted).IsFalse(); + signal.Dispose(); + await acknowledgement; + await Assert.That(acknowledgement.IsCompletedSuccessfully).IsTrue(); + } + /// Verifies that OnNext, OnError and a duplicate OnCompleted /// arriving after the source has already completed are silently dropped. /// A representing the asynchronous test operation. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleAndWaitOperatorCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleAndWaitOperatorCoverageTests.cs index ad5e7228..76787e21 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleAndWaitOperatorCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleAndWaitOperatorCoverageTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for ThrottleOnScheduler, WaitUntil, -/// TakeUntilInclusive, and SynchronizeAsync — paths the happy-path -/// tests do not reach (error propagation, predicate throws, dispose, completion-flush). +/// Tests throttle and wait termination, predicate failures, and disposal. public class ThrottleAndWaitOperatorCoverageTests { /// Throttle interval used across the scheduler-driven tests. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs index 3745a38a..8606f6ca 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleDistinctObservableTests.cs @@ -2,12 +2,13 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Tests for ThrottleDistinctObservable — the after-terminal guards on the -/// distinct-throttle sink, exercised via a source that pushes events past its own completion. +/// Tests distinct throttle delivery after termination and disposal. public class ThrottleDistinctObservableTests { /// Tick window for advancing past the throttle in settle assertions. @@ -16,6 +17,30 @@ public class ThrottleDistinctObservableTests /// Tick window for the throttle itself. private const int ThrottleTicks = 10; + /// Verifies a distinct-throttle callback has no effect without a pending value or after disposal. + /// A task representing the asynchronous test operation. + [Test] + public async Task DistinctThrottleCallbacksRequireAPendingLiveValue() + { + const int First = 1; + const int Disposed = 2; + List values = []; + VirtualClock scheduler = new(); + ThrottleDistinctObservable.ThrottleDistinctSink sink = new( + Observer.Create(values.Add), + TimeSpan.FromTicks(1), + scheduler); + sink.Emit(); + await Assert.That(values).IsEmpty(); + sink.OnNext(First); + sink.Emit(); + sink.Emit(); + sink.OnNext(Disposed); + sink.Dispose(); + sink.Emit(); + await Assert.That(values).IsCollectionEqualTo([First]); + } + /// Verifies that an OnNext arriving after completion is silently dropped. /// A representing the asynchronous test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs index 764d7069..b63f535b 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleFirstObservableTests.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for ThrottleFirst backed by -/// ThrottleFirstObservable<T> — error/completion forwarding and -/// post-terminal behaviour not exercised by the happy-path window test. +/// Tests throttle termination and suppression of subsequent values. public class ThrottleFirstObservableTests { /// Message attached to synthetic source errors. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleObservableTests.cs index 365bb8fc..4f708c67 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleObservableTests.cs @@ -2,13 +2,13 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Tests for ThrottleObservable — the after-terminal guards on -/// OnNext / OnError / OnCompleted that are only reachable when -/// an upstream pushes events past its own completion. +/// Tests throttle delivery after replacement, termination, and disposal. public class ThrottleObservableTests { /// Tick window for advancing past the throttle in settle assertions. @@ -17,6 +17,32 @@ public class ThrottleObservableTests /// Tick window for the throttle itself. private const int ThrottleTicks = 10; + /// Verifies stale, duplicate, and disposed throttle callbacks cannot emit. + /// A task representing the asynchronous test operation. + [Test] + public async Task ThrottleCallbacksEmitOnlyTheCurrentValueOnce() + { + const int First = 1; + const int Latest = 2; + const int Disposed = 3; + List values = []; + VirtualClock scheduler = new(); + ThrottleObservable.ThrottleSink sink = new( + Observer.Create(values.Add), + TimeSpan.FromTicks(1), + scheduler); + sink.OnNext(First); + sink.OnNext(Latest); + sink.Emit(First); + await Assert.That(values).IsEmpty(); + sink.Emit(Latest); + sink.Emit(Latest); + sink.OnNext(Disposed); + sink.Dispose(); + sink.Emit(Disposed); + await Assert.That(values).IsCollectionEqualTo([Latest]); + } + /// Verifies that an OnNext arriving after the source has already completed is silently dropped by the throttle sink. /// A representing the asynchronous test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs index 04722e09..cb699f87 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ThrottleUntilTrueObservableTests.cs @@ -8,9 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for ThrottleUntilTrue backed by -/// ThrottleUntilTrueObservable<T> — predicate-true bypass, predicate-false -/// throttling, error forwarding, completion forwarding, and dispose-before-fire. +/// Tests predicate bypass, delayed values, termination, and cancellation of pending values. public class ThrottleUntilTrueObservableTests { /// Synthetic error message attached to source errors. @@ -55,7 +53,7 @@ public async Task WhenThrottleUntilTruePredicateFalse_ThenEmitsAfterWindow() await Assert.That(emitted).IsCollectionEqualTo([NonMatchingValue]); } - /// Verifies that a later throttled value replaces an earlier still-pending one. + /// Verifies a later throttled value replaces the pending value. /// A representing the asynchronous test operation. [Test] public async Task WhenThrottleUntilTrueFastReplacements_ThenLatestWins() @@ -145,14 +143,15 @@ public async Task WhenEventsAfterCompleted_ThenDropped() await Assert.That(caught).IsNull(); } - /// Builds the operator over a clock the test advances, so no emission depends on wall time. + /// Creates a throttle using the supplied clock and bypass predicate. /// The source sequence. /// The virtual clock timing throttled emissions. /// The bypass predicate. /// The throttled sequence. - private static IObservable Throttled( + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static ThrottleUntilTrueObservable Throttled( IObservable source, VirtualClock scheduler, Func predicate) => - new ThrottleUntilTrueObservable(source, ThrottleWindow, predicate, scheduler); + new(source, ThrottleWindow, predicate, scheduler); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs index 3643e01c..552be18b 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.SecondaryDispose.cs @@ -31,21 +31,21 @@ public async Task WhenActionAndDisposeBothThrow_ThenPrimaryActionErrorForwardedA [Test] public async Task WhenSchedulerPathActionThrows_ThenForwardsErrorAndDisposes() { + VirtualClock scheduler = new(); CountingDisposable resource = new(); TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new("scheduler action failed"); - using var sub = resource.Using(_ => throw expected, TaskPoolSequencer.Default) + using var sub = resource.Using(_ => throw expected, scheduler) .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + scheduler.Start(); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); await Assert.That(resource.DisposeCount).IsEqualTo(1); } - /// Disposable that delegates the side-effect of Dispose to a caller-supplied - /// . Used by tests that intentionally exercise the secondary-failure - /// swallow branch of UsingActionObservable by passing a throwing hook. + /// Invokes a supplied disposal action, including actions that throw. private sealed class HookDisposable : IDisposable { /// Per-dispose hook invoked from . diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.cs index 47c5ef6b..74d74e71 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingActionObservableTests.cs @@ -6,20 +6,12 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the action-form Using operator backed by -/// UsingActionObservable<T> — happy path, null action, scheduler dispatch, -/// and action-throws-then-disposes paths. +/// Tests inline and scheduled actions and resource disposal after success or failure. public partial class UsingActionObservableTests { /// Synthetic error message attached to action failures. private const string ActionFailedMessage = "action failed"; - /// How long to spin waiting for the scheduler thread to land the resource dispose before giving up. - private const int DisposeWaitMilliseconds = 5000; - - /// Guard timeout so a hung rendezvous fails this test rather than stalling the run. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that Using with a null action still emits, completes, and disposes the resource. /// A representing the asynchronous test operation. [Test] @@ -79,24 +71,16 @@ public async Task WhenUsingActionThrows_ThenForwardsErrorAndDisposes() [Test] public async Task WhenUsingWithScheduler_ThenRunsViaScheduler() { + VirtualClock scheduler = new(); TrackedDisposable resource = new(); - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); var actionRan = false; - - using var sub = resource.Using(_ => actionRan = true, TaskPoolSequencer.Default) - .Subscribe(static _ => { }, () => completed.TrySetResult(true)); - - await completed.Task.WaitAsync(GuardTimeout); + var completed = false; + using var sub = resource.Using(_ => actionRan = true, scheduler) + .Subscribe(static _ => { }, () => completed = true); + await Assert.That(actionRan).IsFalse(); + scheduler.Start(); await Assert.That(actionRan).IsTrue(); - - // OnCompleted is signalled before the resource is disposed on the scheduler - // thread, so wait briefly for the dispose to land. - var deadline = Environment.TickCount64 + DisposeWaitMilliseconds; - while (resource.DisposeCount == 0 && Environment.TickCount64 < deadline) - { - await Task.Yield(); - } - + await Assert.That(completed).IsTrue(); await Assert.That(resource.DisposeCount).IsEqualTo(1); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs index d09cc587..e795b72a 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingAndSwitchIfEmptyEdgeTests.cs @@ -15,12 +15,6 @@ public class UsingAndSwitchIfEmptyEdgeTests /// Sentinel value the fallback observable would emit if it were subscribed. private const int FallbackSentinel = 99; - /// How long the scheduler test spins for the resource dispose to land, in milliseconds. - private const int DisposeWaitMilliseconds = 5000; - - /// Longest a test waits for an asynchronous signal before failing. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies that Using with a null action still emits RxVoid, completes, and disposes the resource. /// A representing the asynchronous test operation. [Test] @@ -55,23 +49,17 @@ public async Task WhenUsingActionThrows_ThenForwardsErrorAndDisposes() [Test] public async Task WhenUsingActionWithScheduler_ThenRunsOnScheduler() { + VirtualClock scheduler = new(); TrackedDisposable resource = new(); var ran = false; - TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var sub = resource.Using(_ => ran = true, Sequencer.Default).Subscribe( + var completed = false; + using var sub = resource.Using(_ => ran = true, scheduler).Subscribe( static _ => { }, - () => completed.TrySetResult()); - await completed.Task.WaitAsync(GuardTimeout); + () => completed = true); + await Assert.That(ran).IsFalse(); + scheduler.Start(); await Assert.That(ran).IsTrue(); - - // OnCompleted is signalled before the resource is disposed on the scheduler - // thread, so spin briefly for the dispose to land. - var deadline = Environment.TickCount64 + DisposeWaitMilliseconds; - while (resource.DisposeCount == 0 && Environment.TickCount64 < deadline) - { - await Task.Yield(); - } - + await Assert.That(completed).IsTrue(); await Assert.That(resource.DisposeCount).IsEqualTo(1); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingFuncObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingFuncObservableTests.cs index 820fd2f3..7fdf6dbb 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingFuncObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/UsingFuncObservableTests.cs @@ -14,9 +14,6 @@ public class UsingFuncObservableTests /// Sentinel result emitted by the happy-path test. private const int Sentinel = 42; - /// Longest a test waits for an asynchronous signal before failing. - private static readonly TimeSpan GuardTimeout = TimeSpan.FromSeconds(5); - /// Verifies the happy path — the function's result is emitted, completion fires, /// and the resource is disposed exactly once. /// A representing the asynchronous test operation. @@ -58,15 +55,17 @@ public async Task WhenFunctionAndDisposeBothThrow_ThenPrimaryErrorForwardedAndDi [Test] public async Task WhenSchedulerPathFunctionThrows_ThenForwardsErrorAndDisposes() { + VirtualClock scheduler = new(); CountingDisposable resource = new(); TaskCompletionSource faulted = new(TaskCreationOptions.RunContinuationsAsynchronously); InvalidOperationException expected = new("scheduler function failed"); using var sub = resource - .Using(new Func(_ => throw expected), TaskPoolSequencer.Default) + .Using(new Func(_ => throw expected), scheduler) .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); + scheduler.Start(); - var caught = await faulted.Task.WaitAsync(GuardTimeout); + var caught = await faulted.Task; await Assert.That(caught).IsSameReferenceAs(expected); await Assert.That(resource.DisposeCount).IsEqualTo(1); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs index 561a3fb2..dd7ca734 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/WhileObservableTests.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Edge-case coverage for the While operator backed by -/// WhileObservable — inline iteration, scheduler dispatch, predicate-throws, -/// action-throws, and dispose-during-iteration paths. +/// Tests iteration, scheduling, predicate and action failures, and disposal. public class WhileObservableTests { /// Synthetic error message attached to predicate failures. @@ -118,18 +116,22 @@ private sealed class ManualSequencer : ISequencer public long Timestamp => 0; /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item) => _pending.Enqueue(item); /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item, long dueTimestamp) => Schedule(item); /// Runs the oldest queued work item, if any. internal void RunNext() { - if (_pending.Count > 0) + if (_pending.Count == 0) { - _pending.Dequeue().Execute(); + return; } + + _pending.Dequeue().Execute(); } /// Drains the queue, including work items queued by the items it runs. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs index c1c28aea..8c1c19c9 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsPortedTests.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests; -/// Ported coverage for the migrated synchronous extension operators using primitives runtime types. +/// Tests synchronous extension operators with primitives runtime types. public sealed class ReactiveExtensionsPortedTests { /// Candidate keys probed by the first-match test. @@ -244,7 +244,6 @@ public async Task AsyncAndSequentialHelpersEmitExpectedValues() using var conSub = source .SelectAsyncConcurrent(static x => Task.FromResult(x * ConcurrentMultiplier), MaxConcurrency) .Subscribe(concurrent.Add); - // Both selectors return an already-completed task, so their projections land inside OnNext. source.OnNext(InputValue); List runAll = []; using var runAllSub = new[] { Observable.Return(RxVoid.Default), Observable.Return(RxVoid.Default) }.RunAll() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FusedSelect.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FusedSelect.cs index 6cc6e73b..c54045cd 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FusedSelect.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.FusedSelect.cs @@ -86,9 +86,7 @@ public async Task WhenSelectManyThen_ThenChainsTwoProjections() const int ExpectedChained = 11; List results = []; - // Single source emission keeps the test deterministic — the operator's downstream - // completes once the inner-inner observable completes, so a multi-emission source - // would race against the early-completion semantic. + // The inner sequence completes the result after this emission. using var subscription = Observable.Return(1) .SelectManyThen( static x => Observable.Return(x * SampleValue10), @@ -117,7 +115,7 @@ public async Task WhenRunAll_ThenEmitsSingleUnitAfterAllComplete() results.Add, () => completed.TrySetResult()); - await completed.Task.WaitAsync(WaitTimeout); + await completed.Task; await Assert.That(results).Count().IsEqualTo(1); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs index 722bd928..aba9ea10 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Misc.cs @@ -22,16 +22,13 @@ public partial class ReactiveExtensionsTests /// Value at which the TakeUntil/WaitUntil predicates trip. private const int PredicateThreshold = 5; - /// Hoisted source array used by tests (was inline literal). + /// Source values for string filtering tests. private static readonly string[] SequenceTest123HelloTest456World = ["test123", "hello", "test456", "world"]; /// Expected sequence [1, 2, 3, 4, 5] for collection equality assertions. private static readonly int[] ExpectedSequence12345 = [1, 2, 3, 4, 5]; - /// Longest a test waits for an asynchronous signal before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// Inactivity window long enough that a buffer can only be flushed by completion, never by a timeout. + /// The inactivity interval used with the virtual clock. private static readonly TimeSpan InactivityWindow = TimeSpan.FromSeconds(5); /// Syncronizes the asynchronous runs with asynchronous tasks in subscriptions. @@ -39,9 +36,7 @@ public partial class ReactiveExtensionsTests [Test] public async Task SyncronizeAsync_RunsWithAsyncTasksInSubscriptions() { - // Given, When. The six handlers can run concurrently, so the counters use Interlocked; - // awaiting WhenAll is what establishes that every handler ran, because each one bumps - // itterations in its finally before its task completes. + // Each handler completes after updating the shared counters. var result = 0; var itterations = 0; Subject subject = new(); @@ -112,10 +107,10 @@ public async Task Filter_WithRegex_FiltersStrings() await Assert.That(results).IsCollectionEqualTo(["test123", "test456"]); } - /// Tests Shuffle randomizes array. + /// Verifies Shuffle preserves every input element. /// A representing the asynchronous test operation. [Test] - public async Task Shuffle_RandomizesArray() + public async Task Shuffle_PreservesInputElements() { const int SampleSize = 100; var original = Enumerable.Range(1, SampleSize).ToArray(); @@ -408,8 +403,7 @@ public async Task ReplayLastOnSubscribe_ReplaysLastValueToNewSubscribers() List results2 = []; using var sub2 = replayed.Subscribe(results2.Add); - // Each subscriber gets its own replay hub seeded with the initial value, so a late subscriber - // observes that initial value rather than values delivered to earlier subscribers. + // Each subscriber receives its own replay hub and initial value. await Assert.That(results2).IsCollectionEqualTo([SampleValue99]); subject.OnNext(SampleValue2); using (Assert.Multiple()) @@ -616,7 +610,7 @@ public async Task WhenRetryWithBackoffTwoArgOverload_ThenRetriesUntilSuccess() return EmptyDisposable.Instance; }); const int MaxRetries = 3; - using var sub = source.RetryWithBackoff(MaxRetries, TimeSpan.FromMilliseconds(1)) + using var sub = source.RetryWithBackoff(MaxRetries, TimeSpan.Zero) .Subscribe(values.Add, () => done.TrySetResult(values)); var captured = await done.Task; await Assert.That(captured).IsCollectionEqualTo([SuccessAttempt]); @@ -631,12 +625,13 @@ public void WhenReplayLastOnSubscribeSourceNull_ThenThrows() => /// Verifies the two-argument BufferUntilInactive overload flushes a buffer on completion using the default scheduler. /// A representing the asynchronous test operation. [Test] - public async Task WhenBufferUntilInactiveTwoArgOverload_ThenFlushesBufferOnCompletion() + public async Task WhenBufferUntilInactiveSourceCompletes_ThenFlushesBuffer() { Subject subject = new(); List> results = []; TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var sub = subject.BufferUntilInactive(InactivityWindow) + VirtualClock scheduler = new(); + using var sub = subject.BufferUntilInactive(InactivityWindow, scheduler) .Subscribe(results.Add, () => completed.TrySetResult()); subject.OnNext(1); subject.OnNext(SampleValue2); @@ -777,11 +772,7 @@ public string TestProperty } = string.Empty; } - /// INPC owner whose property type lets us pass a non-member expression body - /// (e.g. a literal arithmetic expression) into ToPropertyObservable so the - /// as MemberExpression ?? throw guard fires. The PropertyChanged event is - /// required by the interface but never raised — the guard short-circuits before - /// subscription wiring runs. + /// Provides a notification owner for rejecting non-member property expressions. private sealed class ToPropertyNonMemberOwner : INotifyPropertyChanged { /// diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Retry.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Retry.cs index 77a3913c..8e7f96df 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Retry.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Retry.cs @@ -3,18 +3,15 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Linq; -using ReactiveUI.Primitives.Async.Tests; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Extensions.Operators; namespace ReactiveUI.Primitives.Extensions.Tests; /// Tests for ReactiveExtensionsTests. public partial class ReactiveExtensionsTests { - /// Longest a retry test waits for the final error to propagate after the retries are exhausted. - private static readonly TimeSpan ErrorPropagationTimeout = TimeSpan.FromSeconds(2); - /// Tests OnErrorRetry without parameters. /// A representing the asynchronous test operation. [Test] @@ -54,32 +51,38 @@ public async Task RetryWithBackoff_RespectsMaxDelay() { const int SuccessAttempt = 5; const int MaxRetries = 10; - const int InitialDelayMilliseconds = 10; + const long InitialDelayTicks = 10; const double BackoffFactor = 2.0; - const int MaxDelayMilliseconds = 50; + const long MaxDelayTicks = 50; + const long SecondDelayTicks = 20; + const long ThirdDelayTicks = 40; + VirtualClock scheduler = new(); var attempts = 0; - var source = Observable.Create(observer => + var source = Observable.Defer(() => { attempts++; - if (attempts < SuccessAttempt) - { - observer.OnError(new InvalidOperationException()); - } - else - { - observer.OnNext(SampleValue42); - observer.OnCompleted(); - } - - return EmptyDisposable.Instance; + return attempts < SuccessAttempt + ? Observable.Throw(new InvalidOperationException()) + : Observable.Return(SampleValue42); }); - var result = source.RetryWithBackoff( + List results = []; + using var subscription = source.RetryWithBackoff( MaxRetries, - TimeSpan.FromMilliseconds(InitialDelayMilliseconds), + TimeSpan.FromTicks(InitialDelayTicks), BackoffFactor, - TimeSpan.FromMilliseconds(MaxDelayMilliseconds), - null).Wait(); - await Assert.That(result).IsEqualTo(SampleValue42); + TimeSpan.FromTicks(MaxDelayTicks), + scheduler).Subscribe(results.Add); + long[] expectedDelays = [InitialDelayTicks, SecondDelayTicks, ThirdDelayTicks, MaxDelayTicks]; + foreach (var delay in expectedDelays) + { + var before = attempts; + scheduler.AdvanceBy(delay - 1); + await Assert.That(attempts).IsEqualTo(before); + scheduler.AdvanceBy(1); + await Assert.That(attempts).IsEqualTo(before + 1); + } + + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); } /// Tests OnErrorRetry with error action and retry count. @@ -87,6 +90,7 @@ public async Task RetryWithBackoff_RespectsMaxDelay() [Test] public async Task OnErrorRetry_WithErrorActionAndRetryCount_RetriesLimitedTimes() { + VirtualClock scheduler = new(); const int RetryCount = 3; const int ExpectedAttempts = RetryCount + 1; var attempts = 0; @@ -102,6 +106,7 @@ public async Task OnErrorRetry_WithErrorActionAndRetryCount_RetriesLimitedTimes( .Subscribe( static _ => { }, ex => caughtException = ex); + scheduler.Start(); using (Assert.Multiple()) { // retryCount = retries after the initial attempt; total subscriptions = 1 + retryCount. @@ -117,34 +122,33 @@ public async Task OnErrorRetry_WithDelay_DelaysRetries() { const int SuccessAttempt = 3; const int RetryCount = 5; - const int DelayMilliseconds = 50; + const long DelayTicks = 50; + VirtualClock scheduler = new(); var attempts = 0; - var source = Observable.Create(observer => + var source = Observable.Defer(() => { attempts++; - if (attempts < SuccessAttempt) - { - observer.OnError(new InvalidOperationException()); - } - else - { - observer.OnNext(SampleValue42); - observer.OnCompleted(); - } - - return EmptyDisposable.Instance; + return attempts < SuccessAttempt + ? Observable.Throw(new InvalidOperationException()) + : Observable.Return(SampleValue42); }); - var startTimestamp = TimeProvider.System.GetTimestamp(); - var result = source.OnErrorRetry( - static ex => { }, + List results = []; + using var subscription = source.OnErrorRetry( + static _ => { }, RetryCount, - TimeSpan.FromMilliseconds(DelayMilliseconds)).Wait(); - var elapsed = TimeProvider.System.GetElapsedTime(startTimestamp); - using (Assert.Multiple()) - { - await Assert.That(result).IsEqualTo(SampleValue42); - await Assert.That(elapsed.TotalMilliseconds).IsGreaterThanOrEqualTo(MinimumExpectedMilliseconds); - } + TimeSpan.FromTicks(DelayTicks), + scheduler).Subscribe(results.Add); + + scheduler.AdvanceBy(DelayTicks - 1); + await Assert.That(attempts).IsEqualTo(1); + await Assert.That(results).IsEmpty(); + scheduler.AdvanceBy(1); + await Assert.That(attempts).IsEqualTo(SampleValue2); + scheduler.AdvanceBy(DelayTicks - 1); + await Assert.That(results).IsEmpty(); + scheduler.AdvanceBy(1); + await Assert.That(attempts).IsEqualTo(SuccessAttempt); + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); } /// Tests OnErrorRetry with delay and no error action. @@ -191,6 +195,7 @@ public async Task OnErrorRetry_WithDelayAndErrorAction_RetriesWithDelay() [Test] public async Task OnErrorRetry_WithRetryCount_LimitsRetries() { + VirtualClock scheduler = new(); const int RetryCount = 2; const int ExpectedErrorCallbacks = RetryCount + 1; var attemptCount = 0; @@ -205,13 +210,12 @@ public async Task OnErrorRetry_WithRetryCount_LimitsRetries() _ = source.OnErrorRetry(ex => errorsCaught++, RetryCount).Subscribe( static _ => { }, ex => finalError = true); - var finalErrorReceived = - await AsyncTestHelpers.WaitForConditionAsync(() => finalError, ErrorPropagationTimeout); + scheduler.Start(); using (Assert.Multiple()) { // OnError callback fires for every failure (including the final propagated one): // 1 initial attempt + retryCount retries = retryCount + 1 callbacks. - await Assert.That(finalErrorReceived).IsTrue(); + await Assert.That(finalError).IsTrue(); await Assert.That(errorsCaught).IsEqualTo(ExpectedErrorCallbacks); await Assert.That(finalError).IsTrue(); } @@ -258,6 +262,7 @@ public async Task OnErrorRetry_WithRetryCountAndDelay_LimitsRetriesWithDelay() [Test] public async Task OnErrorRetry_WithRetryCountDelayAndScheduler_RetriesCorrectly() { + VirtualClock scheduler = new(); const int SuccessAttempt = 2; const int DelayMilliseconds = 10; var attemptCount = 0; @@ -284,6 +289,7 @@ public async Task OnErrorRetry_WithRetryCountDelayAndScheduler_RetriesCorrectly( RetryCount, TimeSpan.FromMilliseconds(DelayMilliseconds), Sequencer.Immediate).Subscribe(r => result = r); + scheduler.Start(); using (Assert.Multiple()) { await Assert.That(errorsCaught).IsEqualTo(1); @@ -328,6 +334,7 @@ public async Task WhenOnErrorRetryWithActionOnly_ThenRetriesImmediately() [Test] public async Task WhenOnErrorRetryWithDelay_ThenRetriesAfterDelay() { + VirtualClock scheduler = new(); const int SuccessAttempt = 2; const int DelayMilliseconds = 10; var attempts = 0; @@ -346,9 +353,14 @@ public async Task WhenOnErrorRetryWithDelay_ThenRetriesAfterDelay() return EmptyDisposable.Instance; }); - var result = source.OnErrorRetry( + var result = 0; + using var subscription = source.OnErrorRetry( static ex => { }, - TimeSpan.FromMilliseconds(DelayMilliseconds)).Wait(); + int.MaxValue, + TimeSpan.FromMilliseconds(DelayMilliseconds), + scheduler) + .Subscribe(value => result = value); + scheduler.Start(); await Assert.That(result).IsEqualTo(SampleValue42); } @@ -357,6 +369,7 @@ public async Task WhenOnErrorRetryWithDelay_ThenRetriesAfterDelay() [Test] public async Task WhenRetryWithBackoffExceedsMaxRetries_ThenRethrows() { + VirtualClock scheduler = new(); var source = Observable.Throw(new InvalidOperationException("fail")); Exception? caughtError = null; const int MaxRetries = 2; @@ -366,9 +379,10 @@ public async Task WhenRetryWithBackoffExceedsMaxRetries_ThenRethrows() TimeSpan.FromMilliseconds(1), BackoffFactor, null, - Sequencer.Immediate).Subscribe( + scheduler).Subscribe( static _ => { }, ex => caughtError = ex); + scheduler.Start(); await Assert.That(caughtError).IsNotNull(); } @@ -377,6 +391,7 @@ public async Task WhenRetryWithBackoffExceedsMaxRetries_ThenRethrows() [Test] public async Task WhenRetryWithBackoffDelayExceedsMax_ThenCapsDelay() { + VirtualClock scheduler = new(); const int SuccessAttempt = 4; const int MaxRetries = 5; const int InitialDelayMilliseconds = 5; @@ -398,12 +413,14 @@ public async Task WhenRetryWithBackoffDelayExceedsMax_ThenCapsDelay() return EmptyDisposable.Instance; }); - var result = source.RetryWithBackoff( + var result = 0; + using var subscription = source.RetryWithBackoff( MaxRetries, TimeSpan.FromMilliseconds(InitialDelayMilliseconds), BackoffFactor, TimeSpan.FromMilliseconds(MaxDelayMilliseconds), - Sequencer.Immediate).Wait(); + scheduler).Subscribe(value => result = value); + scheduler.Start(); await Assert.That(result).IsEqualTo(SampleValue42); } @@ -412,6 +429,7 @@ public async Task WhenRetryWithBackoffDelayExceedsMax_ThenCapsDelay() [Test] public async Task WhenRetryWithDelay_ThenRetriesWithCustomDelay() { + VirtualClock scheduler = new(); const int SuccessAttempt = 3; const int MaxRetries = 5; var attempts = 0; @@ -430,7 +448,9 @@ public async Task WhenRetryWithDelay_ThenRetriesWithCustomDelay() return EmptyDisposable.Instance; }); - var result = source.RetryWithDelay(MaxRetries, static attempt => TimeSpan.FromMilliseconds(1)).Wait(); + var result = 0; + using var subscription = new RetryWithDelayObservable(source, MaxRetries, static attempt => TimeSpan.FromMilliseconds(1), scheduler).Subscribe(value => result = value); + scheduler.Start(); await Assert.That(result).IsEqualTo(SampleValue42); } @@ -441,23 +461,24 @@ public async Task WhenRetryForeverWithDelay_ThenRetriesIndefinitely() { const int SuccessAttempt = 4; var attempts = 0; - var source = Observable.Create(observer => + VirtualClock scheduler = new(); + var delay = TimeSpan.FromTicks(1); + var source = Observable.Defer(() => { attempts++; - if (attempts < SuccessAttempt) - { - observer.OnError(new InvalidOperationException()); - } - else - { - observer.OnNext(SampleValue42); - observer.OnCompleted(); - } - - return EmptyDisposable.Instance; + return attempts < SuccessAttempt + ? Observable.Throw(new InvalidOperationException()) + : Observable.Return(SampleValue42); }); - var result = source.RetryForeverWithDelay(TimeSpan.FromMilliseconds(1)).Wait(); - await Assert.That(result).IsEqualTo(SampleValue42); + List results = []; + using var sub = new RetryWithDelayObservable(source, int.MaxValue, _ => delay, scheduler) + .Subscribe(results.Add); + await Assert.That(attempts).IsEqualTo(1); + scheduler.AdvanceBy(delay.Ticks); + await Assert.That(attempts).IsEqualTo(SampleValue2); + scheduler.Start(); + await Assert.That(attempts).IsEqualTo(SuccessAttempt); + await Assert.That(results).IsCollectionEqualTo([SampleValue42]); } /// Tests RetryWithFixedDelay retries with constant delay between retries. @@ -465,6 +486,7 @@ public async Task WhenRetryForeverWithDelay_ThenRetriesIndefinitely() [Test] public async Task WhenRetryWithFixedDelay_ThenRetriesWithConstantDelay() { + VirtualClock scheduler = new(); const int SuccessAttempt = 3; const int MaxRetries = 5; var attempts = 0; @@ -483,7 +505,9 @@ public async Task WhenRetryWithFixedDelay_ThenRetriesWithConstantDelay() return EmptyDisposable.Instance; }); - var result = source.RetryWithFixedDelay(MaxRetries, TimeSpan.FromMilliseconds(1)).Wait(); + var result = 0; + using var subscription = new RetryWithBackoffObservable(source, new(MaxRetries, TimeSpan.FromMilliseconds(1), 1.0, null, scheduler, null)).Subscribe(value => result = value); + scheduler.Start(); await Assert.That(result).IsEqualTo(SampleValue42); } @@ -526,6 +550,7 @@ public async Task WhenRetryWithBackoffInnerRetry_ThenRetriesAndCapsDelay() [Test] public async Task WhenOnErrorRetryNegativeDelay_ThenUsesZeroDelay() { + VirtualClock scheduler = new(); const int SuccessAttempt = 3; var attempt = 0; var source = Observable.Defer(() => @@ -542,14 +567,16 @@ public async Task WhenOnErrorRetryNegativeDelay_ThenUsesZeroDelay() _ = source.OnErrorRetry( static _ => { }, RetryCount, - TimeSpan.FromTicks(-1)).Subscribe( + TimeSpan.FromTicks(-1), + scheduler).Subscribe( v => { results.Add(v); _ = received.TrySetResult(); }, ex => error = ex); - await received.Task.WaitAsync(WaitTimeout); + scheduler.Start(); + await received.Task; await Assert.That(results).Contains(SampleValue42); } @@ -558,6 +585,7 @@ public async Task WhenOnErrorRetryNegativeDelay_ThenUsesZeroDelay() [Test] public async Task WhenOnErrorRetryExceedsRetryCount_ThenRethrows() { + VirtualClock scheduler = new(); var source = Observable.Throw(new InvalidOperationException("fail")); Exception? caught = null; TaskCompletionSource errorReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -565,14 +593,16 @@ public async Task WhenOnErrorRetryExceedsRetryCount_ThenRethrows() _ = source.OnErrorRetry( static _ => { }, RetryCount, - TimeSpan.Zero).Subscribe( + TimeSpan.Zero, + scheduler).Subscribe( static _ => { }, ex => { caught = ex; _ = errorReceived.TrySetResult(); }); - await errorReceived.Task.WaitAsync(WaitTimeout); + scheduler.Start(); + await errorReceived.Task; await Assert.That(caught).IsNotNull(); await Assert.That(caught).IsTypeOf(); } @@ -675,6 +705,7 @@ public async Task WhenRetryWithBackoffDelayExceedsMaxDelay_ThenCappedAtMaxDelay( [Test] public async Task WhenRetryWithBackoffComputedDelayExceedsMaxDelay_ThenCappedToMaxDelay() { + VirtualClock scheduler = new(); const int FailingAttempts = 2; const int MaxRetries = 5; const double BackoffFactor = 1000.0; @@ -697,15 +728,14 @@ public async Task WhenRetryWithBackoffComputedDelayExceedsMaxDelay_ThenCappedToM return EmptyDisposable.Instance; }); - - // initialDelay=1ms, backoffFactor=1000 => computed delay = 1000ms >> maxDelay=2ms - // This ensures the cap path at line 1240 is hit - var result = source.RetryWithBackoff( + var result = 0; + using var subscription = source.RetryWithBackoff( MaxRetries, TimeSpan.FromMilliseconds(1), BackoffFactor, TimeSpan.FromMilliseconds(MaxDelayMilliseconds), - Sequencer.Immediate).Wait(); + scheduler).Subscribe(value => result = value); + scheduler.Start(); await Assert.That(result).IsEqualTo(SampleValue42); await Assert.That(attemptCount).IsEqualTo(SampleValue3); } @@ -772,19 +802,17 @@ public async Task WhenRetryWithBackoff_GivenLargeBackoffFactor_ThenDelayIsCapped [Test] public async Task WhenRetryWithDelayExhaustsRetries_ThenRethrowsOriginalException() { - // Given — source always fails + VirtualClock scheduler = new(); const int MaxRetries = 2; var source = Observable.Throw(new InvalidOperationException("permanent")); TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); // When - using var sub = source.RetryWithDelay(MaxRetries, static _ => TimeSpan.FromMilliseconds(1)).Subscribe( + using var sub = new RetryWithDelayObservable(source, MaxRetries, static _ => TimeSpan.FromMilliseconds(1), scheduler).Subscribe( static _ => { }, ex => completion.TrySetResult(ex)); + scheduler.Start(); - // Await the error propagation directly rather than polling a flag against a wall-clock - // deadline — the retries run on the default (thread-pool) scheduler, so a fixed budget is - // racy under CI load. var caught = await completion.Task; // Then diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs index 028728a6..e08c8430 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Scheduling.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests; /// Tests for ReactiveExtensions around scheduling. public partial class ReactiveExtensionsTests { - /// Delay used by the schedule tests that run on the immediate/real-time sequencer. + /// The delay advanced by the scheduling tests. private const int ShortScheduleDelayMilliseconds = 10; /// Tests DetectStale marks stream as stale. @@ -237,9 +237,11 @@ public async Task Using_WithFunction_TransformsValue() [Test] public async Task Schedule_WithTimeSpanAndAction_ExecutesAction() { + VirtualClock scheduler = new(); var executed = false; const int Value = 42; - _ = Value.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), Sequencer.Immediate, v => executed = true).Subscribe(); + _ = Value.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), scheduler, v => executed = true).Subscribe(); + scheduler.Start(); await Assert.That(executed).IsTrue(); } @@ -274,10 +276,12 @@ public async Task Schedule_WithObservableDateTimeOffsetAndAction_DelaysAndExecut [Test] public async Task Schedule_WithObservableTimeSpanAndAction_ExecutesAction() { + VirtualClock scheduler = new(); var executed = false; Subject subject = new(); - _ = subject.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), Sequencer.Immediate, v => executed = true).Subscribe(); + _ = subject.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), scheduler, v => executed = true).Subscribe(); subject.OnNext(SampleValue42); + scheduler.Start(); await Assert.That(executed).IsTrue(); } @@ -286,12 +290,14 @@ public async Task Schedule_WithObservableTimeSpanAndAction_ExecutesAction() [Test] public async Task Schedule_WithDateTimeOffsetAndAction_ExecutesAction() { + VirtualClock scheduler = new(); var executed = false; const int Value = 42; _ = Value.Schedule( - TimeProvider.System.GetLocalNow().AddMilliseconds(SampleValue10), - Sequencer.Immediate, + scheduler.Now.AddMilliseconds(SampleValue10), + scheduler, v => executed = true).Subscribe(); + scheduler.Start(); await Assert.That(executed).IsTrue(); } @@ -300,13 +306,15 @@ public async Task Schedule_WithDateTimeOffsetAndAction_ExecutesAction() [Test] public async Task Schedule_WithObservableDateTimeOffsetAndAction_ExecutesAction() { + VirtualClock scheduler = new(); var executed = false; Subject subject = new(); _ = subject.Schedule( - TimeProvider.System.GetLocalNow().AddMilliseconds(SampleValue10), - Sequencer.Immediate, + scheduler.Now.AddMilliseconds(SampleValue10), + scheduler, v => executed = true).Subscribe(); subject.OnNext(SampleValue42); + scheduler.Start(); await Assert.That(executed).IsTrue(); } @@ -338,10 +346,12 @@ public async Task Schedule_WithObservableAndFunction_TransformsValue() [Test] public async Task Schedule_WithTimeSpanAndFunction_TransformsValue() { + VirtualClock scheduler = new(); const int Value = 42; var result = 0; - _ = Value.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), Sequencer.Immediate, static v => v * SampleValue2) + _ = Value.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), scheduler, static v => v * SampleValue2) .Subscribe(r => result = r); + scheduler.Start(); await Assert.That(result).IsEqualTo(SampleValue84); } @@ -350,11 +360,13 @@ public async Task Schedule_WithTimeSpanAndFunction_TransformsValue() [Test] public async Task Schedule_WithObservableTimeSpanAndFunction_TransformsValue() { + VirtualClock scheduler = new(); Subject subject = new(); var result = 0; - _ = subject.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), Sequencer.Immediate, static v => v * SampleValue2) + _ = subject.Schedule(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), scheduler, static v => v * SampleValue2) .Subscribe(r => result = r); subject.OnNext(SampleValue42); + scheduler.Start(); await Assert.That(result).IsEqualTo(SampleValue84); } @@ -459,23 +471,20 @@ public async Task ObserveOnIf_WithReactiveConditionAndSingleScheduler_ObservesOn await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); } - /// Tests SyncTimer without scheduler uses default scheduler and produces ticks. + /// Verifies the initial tick is due immediately and the next tick waits for one period. /// A representing the asynchronous test operation. [Test] - public async Task WhenSyncTimerCalledWithoutScheduler_ThenProducesTicks() + public async Task WhenSyncTimerClockAdvances_ThenProducesTicks() { - const int TickPeriodMilliseconds = 50; + VirtualClock scheduler = new(); + var period = TimeSpan.FromTicks(SchedulerWindowTicks); List results = []; - TaskCompletionSource firstTick = new(); - using var sub = ReactiveExtensions.SyncTimer(TimeSpan.FromMilliseconds(TickPeriodMilliseconds)) - .Take(SampleValue2) - .Subscribe(tick => - { - results.Add(tick); - _ = firstTick.TrySetResult(); - }); - await firstTick.Task; - await Assert.That(results).Count().IsGreaterThanOrEqualTo(1); + using var sub = ReactiveExtensions.SyncTimer(period, scheduler).Take(SampleValue2).Subscribe(results.Add); + await Assert.That(results).IsEmpty(); + scheduler.AdvanceBy(period.Ticks - 1); + await Assert.That(results).Count().IsEqualTo(1); + scheduler.AdvanceBy(1); + await Assert.That(results).Count().IsEqualTo(SampleValue2); } /// Tests Start with null scheduler executes the action directly. @@ -501,19 +510,15 @@ public async Task WhenStartFuncWithNullScheduler_ThenReturnsResult() await Assert.That(result).IsEqualTo(SampleValue42); } - /// Tests ScheduleSafe with a TimeSpan and a null scheduler takes the inline blocking path. + /// Verifies a zero delay and null scheduler execute inline. /// A representing the asynchronous test operation. [Test] - public async Task WhenScheduleSafeWithTimeSpanAndNullScheduler_ThenSleepsAndExecutes() + public async Task WhenScheduleSafeWithZeroDelayAndNullScheduler_ThenExecutes() { var executed = false; const ISequencer? Scheduler = null; - var disposable = Scheduler.ScheduleSafe(TimeSpan.FromMilliseconds(ShortScheduleDelayMilliseconds), () => executed = true); - using (Assert.Multiple()) - { - await Assert.That(executed).IsTrue(); - await Assert.That(disposable).IsNotNull(); - } + using var disposable = Scheduler.ScheduleSafe(TimeSpan.Zero, () => executed = true); + await Assert.That(executed).IsTrue(); } /// Tests Using with Action invokes the action and disposes the resource. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs index 31d41428..d604fcf3 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.SelectAsync.cs @@ -2,6 +2,9 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; +using ReactiveUI.Primitives.Extensions.Operators; + namespace ReactiveUI.Primitives.Extensions.Tests; /// Tests for ReactiveExtensionsTests. @@ -69,22 +72,25 @@ public async Task WhenSelectAsyncSequential_ThenProcessesInOrder() [Test] public async Task WhenSelectLatestAsync_ThenEmitsLatestResult() { - var source = ExpectedSequence123.ToObservable(); List results = []; - TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - _ = source.SelectLatestAsync(static async x => - { - await Task.Yield(); - return x * SampleValue2; - }).Subscribe( - results.Add, - () => tcs.TrySetResult(true)); - - await tcs.Task; - - // Switch means only the latest survives; with sources 1,2,3 and selector x*2, expect [6]. - await Assert.That(results).IsNotEmpty(); + TaskCompletionSource first = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource second = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource latest = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task[] projections = [first.Task, second.Task, latest.Task]; + using SelectLatestAsyncObservable.SelectLatestAsyncSink sink = new( + Observer.Create(results.Add), + value => projections[value - 1]); + + var firstOperation = sink.OnNextAsync(1); + var secondOperation = sink.OnNextAsync(SampleValue2); + var latestOperation = sink.OnNextAsync(SampleValue3); + latest.SetResult(SampleValue6); + await latestOperation; + first.SetResult(SampleValue2); + second.SetResult(SampleValue4); + await Task.WhenAll(firstOperation, secondOperation); + + await Assert.That(results).IsCollectionEqualTo([SampleValue6]); } /// Tests SelectAsyncConcurrent processes tasks concurrently up to max concurrency. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs index 43b6f892..a85ef72c 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Synchronize.cs @@ -17,13 +17,11 @@ public partial class ReactiveExtensionsTests [Test] public async Task SubscribeSynchronus_RunsWithAsyncTasksInSubscriptions() { - // Given, When. SubscribeSynchronous queues each OnNext and drains the queue one handler at - // a time; each handler resumes on a pool thread, so the counters use Interlocked. The - // alternating +1 / -1 handlers cancel out once all six have run. + // Each handler completes after updating the shared counters. var result = 0; var itterations = 0; Subject subject = new(); - TaskCompletionSource allHandled = new(); + TaskCompletionSource allHandled = new(TaskCreationOptions.RunContinuationsAsynchronously); using var disposable = subject.SubscribeSynchronous(async x => { await Task.Yield(); @@ -47,10 +45,7 @@ public async Task SubscribeSynchronus_RunsWithAsyncTasksInSubscriptions() [Test] public async Task SynchronizeSynchronous_RunsWithAsyncTasksInSubscriptions() { - // Given, When. SynchronizeSynchronous dispatches each OnNext through an independent - // Continuation so the six HandleAsync invocations can run concurrently — the int - // read-modify-write therefore needs Interlocked. The test asserts pair-wise - // (+1, -1) sums to zero after WhenAll completes. + // Each handler completes after updating the shared counters. var result = 0; var itterations = 0; Subject subject = new(); @@ -92,13 +87,11 @@ async Task HandleAsync((bool Value, IDisposable Sync) x) [Test] public async Task SubscribeAsync_RunsWithAsyncTasksInSubscriptions() { - // Given, When. SubscribeAsync queues each OnNext and drains the queue one handler at a - // time; each handler resumes on a pool thread, so the counters use Interlocked. The - // alternating +1 / -1 handlers cancel out once all six have run. + // Each handler completes after updating the shared counters. var result = 0; var itterations = 0; Subject subject = new(); - TaskCompletionSource allHandled = new(); + TaskCompletionSource allHandled = new(TaskCreationOptions.RunContinuationsAsynchronously); using var disposable = subject.SubscribeAsync(async x => { await Task.Yield(); @@ -125,40 +118,33 @@ public async Task WithLimitedConcurrency_LimitsConcurrentTasks() const int MaxConcurrency = 3; var inFlight = 0; var maxConcurrent = 0; - Queue> pulled = new(); + var pulled = System.Threading.Channels.Channel.CreateUnbounded>(); List results = []; - var completed = false; - - // Each task only finishes when the test completes its gate, so the pull count the limiter - // holds open is observable exactly rather than sampled while real tasks overlap. + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); IEnumerable> CreateTasks() { for (var i = 1; i <= SampleValue10; i++) { - TaskCompletionSource gate = new(); - pulled.Enqueue(gate); - inFlight++; - maxConcurrent = Math.Max(maxConcurrent, inFlight); + TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + var current = Interlocked.Increment(ref inFlight); + maxConcurrent = Math.Max(maxConcurrent, current); + _ = pulled.Writer.TryWrite(gate); yield return gate.Task; } } using var sub = CreateTasks().WithLimitedConcurrency(MaxConcurrency) - .Subscribe(results.Add, () => completed = true); - var next = 0; - while (pulled.Count > 0) + .Subscribe(results.Add, completed.SetResult); + for (var next = 1; next <= SampleValue10; next++) { - var gate = pulled.Dequeue(); - inFlight--; - gate.SetResult(++next); + var gate = await pulled.Reader.ReadAsync(); + _ = Interlocked.Decrement(ref inFlight); + gate.SetResult(next); } - using (Assert.Multiple()) - { - await Assert.That(results).Count().IsEqualTo(SampleValue10); - await Assert.That(maxConcurrent).IsLessThanOrEqualTo(MaxConcurrency); - await Assert.That(completed).IsTrue(); - } + await completed.Task; + await Assert.That(results).Count().IsEqualTo(SampleValue10); + await Assert.That(maxConcurrent).IsEqualTo(MaxConcurrency); } /// Verifies an empty limited-concurrency task sequence completes immediately. @@ -207,16 +193,15 @@ IEnumerable> FaultingTasks() [Test] public async Task WithLimitedConcurrency_DisposeBeforeTaskContinuation_DropsWork() { - // The limiter attaches its continuation with ExecuteSynchronously, so SetResult runs the - // dropped-work path inline before it returns. - TaskCompletionSource task = new(); + ConcurrencyLimiter limiter = new([], 1); List values = []; Exception? caught = null; var completed = false; - var sub = new[] { task.Task }.WithLimitedConcurrency(1) - .Subscribe(values.Add, ex => caught = ex, () => completed = true); - sub.Dispose(); - task.SetResult(SampleValue10); + ConcurrencyLimiter.Subscription subscription = new( + limiter, + Observer.Create(values.Add, ex => caught = ex, () => completed = true)); + subscription.Dispose(); + limiter.ProcessTaskCompletion(subscription, Task.FromResult(SampleValue10)); await Assert.That(values).IsEmpty(); await Assert.That(caught).IsNull(); await Assert.That(completed).IsFalse(); @@ -311,7 +296,9 @@ public async Task SubscribeAsync_WithOnNextAndOnError_HandlesError() observer.OnError(new InvalidOperationException()); return EmptyDisposable.Instance; }); - using var sub = source.SubscribeAsync(async x => results.Add(x), ex => + using var sub = source.SubscribeAsync( + async x => results.Add(x), + ex => { caughtException = ex; _ = errorSource.TrySetResult(true); diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs index 4d38931a..1bc8fba4 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.Throttle.cs @@ -11,13 +11,13 @@ namespace ReactiveUI.Primitives.Extensions.Tests; /// Tests for ReactiveExtensionsTests. public partial class ReactiveExtensionsTests { - /// Throttle window in virtual ticks used by the real-time-free throttle tests. + /// The throttle window in virtual ticks. private const int ThrottleWindowTicks = 100; - /// Window used by the throttle tests that run on the default sequencer and await their emission. - private static readonly TimeSpan DefaultSequencerWindow = TimeSpan.FromMilliseconds(200); + /// The interval used to verify distinct-value throttling. + private static readonly TimeSpan DistinctThrottleWindow = TimeSpan.FromMilliseconds(200); - /// Debounce window used by the default-sequencer DebounceUntil test. + /// The debounce interval. private static readonly TimeSpan DebounceWindow = TimeSpan.FromMilliseconds(500); /// Tests DebounceImmediate emits first immediately. @@ -62,8 +62,8 @@ public async Task DropIfBusy_DropsWhenBusy() { Subject subject = new(); List results = []; - TaskCompletionSource release = new(); - TaskCompletionSource processed = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource processed = new(TaskCreationOptions.RunContinuationsAsynchronously); _ = subject.DropIfBusy(async x => { await release.Task.ConfigureAwait(false); @@ -226,24 +226,20 @@ public async Task WhenThrottleUntilTruePredicateFalse_ThenAppliesThrottle() await Assert.That(results).Contains(1); } - /// Tests ThrottleDistinct without scheduler parameter. + /// Verifies the throttle window emits the latest distinct value. /// A representing the asynchronous test operation. [Test] - public async Task WhenThrottleDistinctWithoutScheduler_ThenThrottlesAndDeduplicates() + public async Task WhenThrottleDistinctWindowEnds_ThenEmitsLatestDistinctValue() { + VirtualClock scheduler = new(); Subject subject = new(); List results = []; - TaskCompletionSource emitted = new(); - using var sub = subject.ThrottleDistinct(DefaultSequencerWindow).Subscribe(value => - { - results.Add(value); - _ = value == SampleValue2 && emitted.TrySetResult(); - }); + using var sub = subject.ThrottleDistinct(DistinctThrottleWindow, scheduler).Subscribe(results.Add); subject.OnNext(1); subject.OnNext(1); subject.OnNext(SampleValue2); - await emitted.Task; - await Assert.That(results).Contains(SampleValue2); + scheduler.AdvanceBy(DistinctThrottleWindow.Ticks); + await Assert.That(results).IsCollectionEqualTo([SampleValue2]); } /// Tests DebounceUntil with scheduler delays non-matching values and passes matching immediately. @@ -263,19 +259,20 @@ public async Task WhenDebounceUntilWithScheduler_ThenUsesScheduler() await Assert.That(results).Contains(SampleValue3); } - /// Tests DebounceImmediate with null scheduler uses Default scheduler. + /// Verifies the first value is immediate and the trailing value waits for the debounce window. /// A representing the asynchronous test operation. [Test] - public async Task WhenDebounceImmediateNullScheduler_ThenUsesDefault() + public async Task WhenDebounceImmediateWithScheduler_ThenEmitsTrailingValue() { + VirtualClock scheduler = new(); Subject subject = new(); List results = []; - using var sub = subject.DebounceImmediate(PolledThrottleWindow).Subscribe(results.Add); + using var sub = subject.DebounceImmediate(DebounceWindow, scheduler).Subscribe(results.Add); subject.OnNext(1); subject.OnNext(SampleValue2); - await AsyncTestHelpers.WaitForConditionAsync(() => results.Count >= 2, LongWaitTimeout); - await Assert.That(results).Contains(1); - await Assert.That(results).Contains(SampleValue2); + await Assert.That(results).IsCollectionEqualTo([1]); + scheduler.AdvanceBy(DebounceWindow.Ticks); + await Assert.That(results).IsCollectionEqualTo([1, SampleValue2]); } /// Tests DebounceUntil without scheduler emits immediately when condition true. @@ -294,7 +291,7 @@ public async Task WhenDebounceUntilWithoutScheduler_ThenEmitsImmediatelyWhenCond // Even values should emit immediately (condition true) subject.OnNext(SampleValue2); - await received.Task.WaitAsync(WaitTimeout); + await received.Task; await Assert.That(results).Contains(SampleValue2); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.cs index 5f10f670..f2402d35 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.cs @@ -58,9 +58,6 @@ public partial class ReactiveExtensionsTests /// Sample integer value (99) used by multiple tests. private const int SampleValue99 = 99; - /// Minimum-elapsed-time guard in milliseconds for timing assertions. - private const int MinimumExpectedMilliseconds = 90; - /// Throttle/debounce wait window in milliseconds. private const int ThrottleWaitMilliseconds = 150; @@ -76,20 +73,12 @@ public partial class ReactiveExtensionsTests /// One tick past the scheduler window, used to advance virtual time. private const int SchedulerAdvancePastWindowTicks = 101; - /// Short real-time delay in milliseconds. + /// Short delay supplied to virtual schedulers, in milliseconds. private const int ShortDelayMilliseconds = 500; - /// Long real-time delay in milliseconds. + /// Long delay supplied to virtual schedulers, in milliseconds. private const int LongDelayMilliseconds = 1_000; /// Expected sequence [1, 2, 3] for collection equality assertions. private static readonly int[] ExpectedSequence123 = [1, 2, 3]; - -#if NET9_0_OR_GREATER - /// Lock used to synchronize observer callbacks during concurrency tests. - private readonly Lock _gate = new(); -#else - /// Lock used to synchronize observer callbacks during concurrency tests. - private readonly object _gate = new(); -#endif } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveUI.Primitives.Extensions.Tests.csproj b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveUI.Primitives.Extensions.Tests.csproj index 84af6a33..a4eca593 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveUI.Primitives.Extensions.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveUI.Primitives.Extensions.Tests.csproj @@ -12,10 +12,6 @@ - - - - diff --git a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs index 488b28df..e89e69ff 100644 --- a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs @@ -104,7 +104,7 @@ public async Task ImmediateBurstExecutesInOrder() _ = scheduler.Schedule(() => values.Add(captured)); } - await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default); + await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } /// Fake MAUI dispatcher that runs marshalled work synchronously and records how it was dispatched. diff --git a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs index 6985f799..d92168d1 100644 --- a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs @@ -17,9 +17,6 @@ public sealed class MauiDispatcherSequencerTests /// Expected values produced by an immediate burst, used to verify FIFO order. private static readonly int[] ExpectedBurst = [1, 2, 3]; - /// Due time far enough out that the delay reaching the dispatcher is positive whatever the call costs. - private static readonly TimeSpan FutureDueTime = TimeSpan.FromHours(1); - /// Verifies the constructor rejects a null dispatcher. /// A representing the asynchronous operation. [Test] @@ -68,8 +65,7 @@ public async Task DelayedScheduleUsesDispatchDelayed() MauiDispatcherSequencer sequencer = new(dispatcher); var executed = false; - // The relative overload converts the due time against the sequencer's own clock, so the test needs none. - _ = sequencer.Schedule(FutureDueTime, () => executed = true); + sequencer.Schedule(new DelegateWorkItem(() => executed = true), long.MaxValue); await Assert.That(executed).IsTrue(); await Assert.That(dispatcher.DispatchDelayedCount).IsEqualTo(1); @@ -109,7 +105,7 @@ public async Task ImmediateBurstExecutesInOrder() sequencer.Schedule(new DelegateWorkItem(() => values.Add(captured))); } - await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default); + await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } /// Verifies the sequencer surfaces the shared dispatch clock through both clock properties. diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Incremental.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Incremental.cs index 1ee3bc75..859f3775 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Incremental.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Incremental.cs @@ -10,12 +10,6 @@ namespace ReactiveUI.Primitives.ObservableEvents.Tests; /// Verifies the generator's pipeline caches, so an unrelated edit costs nothing to recompute. -/// -/// Correct output alone does not prove a generator is incremental: a pipeline that reruns everything on every -/// keystroke produces exactly the same files. What proves it is the driver's own record of why each step ran, which -/// is what these tests read. They are the guard against a model quietly regaining a symbol, a syntax node, or the -/// compilation, any of which makes every step compare unequal and every file regenerate on every keystroke. -/// public sealed partial class EventGeneratorTests { /// Consumer source that exercises both the instance and the static request routes at once. @@ -79,12 +73,7 @@ public static class Unrelated GeneratorStepNames.StaticNamespaces, ]; - /// The steps that must not recompute at all when an unrelated file is edited. - /// - /// These are the two that run the semantic model, and they are the expensive half of the generator. Accepting - /// Unchanged here would let a regression through: a transform that re-runs and happens to produce an - /// equal value still paid for every symbol walk, which is the cost this pipeline exists to avoid. - /// + /// Asserts that semantic extraction steps reuse their cached results. private static readonly string[] SemanticStepNames = [ GeneratorStepNames.InstanceTargets, @@ -116,8 +105,7 @@ public async Task EventGeneratorCachesEveryPipelineStepWhenNothingChanges() GeneratorDriver driver = CreateTrackingDriver(); driver = driver.RunGenerators(compilation); - // A clone is a different compilation object holding the same trees, so every step is asked again and - // every step has to answer that its value is unchanged. + // A cloned compilation preserves its syntax trees and model values. driver = driver.RunGenerators(compilation.Clone()); var reasons = CollectTrackedStepReasons(driver.GetRunResult()); @@ -156,14 +144,8 @@ await Assert.That(semantic.FindAll( static reason => reason.Reason != IncrementalStepRunReason.Cached)).IsEmpty(); } - /// Verifies the generator emits no post-initialization output, which would defeat all caching. + /// Verifies activation source does not invalidate cached semantic extraction. /// A task representing the asynchronous operation. - /// - /// Post-initialization source is added to the compilation the pipeline runs against, so producing any at all - - /// even one file nothing refers to - makes that compilation new on every run and discards every semantic result - /// cached against the previous one. Measured at roughly a hundredfold on an unchanged re-run, so this is worth - /// a test of its own: the activation API has to arrive as an ordinary source output instead. - /// [Test] [RequiresAssemblyFiles] public async Task EventGeneratorEmitsNoPostInitializationOutput() @@ -181,10 +163,6 @@ [new EventGenerator().AsSourceGenerator()], /// Verifies re-running against the very same compilation recomputes nothing at all. /// A task representing the asynchronous operation. - /// - /// The strongest statement the pipeline can make, and the one that only holds while no post-initialization - /// output exists: handed back the identical compilation, every semantic transform is skipped outright. - /// [Test] [RequiresAssemblyFiles] public async Task EventGeneratorRecomputesNothingForTheSameCompilation() @@ -240,8 +218,7 @@ public async Task EventGeneratorRecomputesOnlyTheStepsAnEventChangeAffects() await Assert.That(reasons.Exists(static reason => reason.StepName == GeneratorStepNames.InstanceTargets && !IsCached(reason.Reason))).IsTrue(); - // Its signature did not move, so the shared overload file must not be rebuilt, and neither the static - // request nor the resolved provider has anything to do with an instance event being added. + // Only this host's wrapper depends on the added event. await Assert.That(reasons.FindAll(static reason => reason.StepName != GeneratorStepNames.InstanceTargets && !IsCached(reason.Reason))) .IsEmpty(); diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Nullability.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Nullability.cs index 0b95e7cf..ba68280e 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Nullability.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Nullability.cs @@ -10,11 +10,6 @@ namespace ReactiveUI.Primitives.ObservableEvents.Tests; /// Verifies generated handlers match the nullability of the delegates they are assigned to. -/// -/// A handler whose parameter nullability differs from its delegate's is assignable but warns (CS8622), and a -/// consumer building warnings-as-errors cannot use the generated wrapper at all. The annotations are therefore part -/// of the contract - but only where the consumer's language version can express them, which is what these pin down. -/// public sealed partial class EventGeneratorTests { /// The nullable directive a generated file opens with when the consumer's language allows it. @@ -26,11 +21,7 @@ public sealed partial class EventGeneratorTests /// The same handler with the annotation dropped, as an older language version requires. private const string ObliviousSenderHandler = "void Handler(object sender, global::System.EventArgs e)"; - /// Consumer source valid on every language version the generator supports. - /// - /// Requests both an instance wrapper and a static one, so every generated file the language gate touches - the - /// wrapper, the shared overloads, and the namespace's static class - is exercised at both language versions. - /// + /// Checks nullability output for instance wrappers, activation overloads, and static event classes. private const string ConventionalEventSource = """ using System; using ReactiveUI.Primitives.ObservableEvents; diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.StaticRequests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.StaticRequests.cs index 7b39a6fd..be4bb3bc 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.StaticRequests.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.StaticRequests.cs @@ -8,11 +8,6 @@ namespace ReactiveUI.Primitives.ObservableEvents.Tests; /// Verifies every spelling of the static request attribute is recognised. -/// -/// The attribute is matched on how it is written rather than on the symbol it binds to, because the type it names -/// is this generator's own output and output is invisible to the pipeline that produced it. That puts the burden of -/// understanding C#'s naming forms on this generator, so each one a consumer may reasonably write is pinned here. -/// public sealed partial class EventGeneratorTests { /// The host every spelling in these tests points at. diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs index df33e3ef..89d95732 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs @@ -39,7 +39,7 @@ public sealed partial class EventGeneratorTests /// The number of deliberately unsupported events in the combined signature test. private const int UnsupportedEventCount = 4; - /// The number of distinct wrappers expected from the sanitization-collision regression. + /// The distinct wrappers required when sanitized identifiers collide. private const int SanitizationCollisionWrapperCount = 2; /// Source that exercises a conventional instance event. diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs index c04ec97c..e79f6de2 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs @@ -26,11 +26,9 @@ public async Task RunDisposesTheStartedWorkWhenCancellationClaimsTheItemDuringIt { BooleanDisposable started = new(); ProbeWorkItem? item = null; - item = new(Scheduler.Default, ProbeState, (_, _) => + item = new(Scheduler.Immediate, ProbeState, (_, _) => { - // Reproduce the interleaving the guard exists for: a concurrent cancel has claimed the single - // dispose transition, but the run's compare-exchange has already published the started work, so - // the run itself is what tears the work down. + // Cancellation claims disposal before Run publishes its started work. _ = item!.ClaimCancellation(); return started; }); diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs index e472d2b4..6b02aa07 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs @@ -5,8 +5,7 @@ using System.Reactive; using System.Reactive.Disposables; -// Imported so the DisposeWith tests below are compiled with System.Reactive's own fluent disposal helpers in -// scope - that is the call-site shape the ContainerDisposable overload exists to keep unambiguous. +// Import both fluent disposal APIs to check overload resolution. using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; using ReactiveUI.Primitives.Advanced; @@ -74,8 +73,7 @@ public async Task DisposeWithContainerReturnsConcreteDisposableAndTracksIt() await Assert.That(result).IsSameReferenceAs(disposable); await Assert.That(disposables.Contains(disposable)).IsTrue(); - // Resolving to this overload rather than System.Reactive's is what keeps the registration on the - // container itself instead of the composite it would have been converted into. + // Register the item on the container, not its implicit composite. await Assert.That(((CompositeDisposable)disposables).Count).IsEqualTo(0); disposables.Dispose(); diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs index de212932..a3705caf 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs @@ -7,12 +7,7 @@ namespace ReactiveUI.Primitives.Reactive.Tests; -/// -/// Exercises every member of the scheduling seam directly, rather than through an operator that happens to use one -/// of them. This part deliberately does not import System.Reactive.Concurrency: the Scheduler class in -/// that namespace carries extension methods with the same signatures, and importing it would make every call below -/// ambiguous. The scheduler types are therefore spelled out in full. -/// +/// Tests scheduling overloads. Fully qualified scheduler names avoid ambiguous System.Reactive extension methods. public partial class SequencerSchedulingExtensionsTests { /// State threaded through the closure-free stateful overloads. @@ -111,10 +106,7 @@ private sealed class CountingWorkItem : IWorkItem public void Execute() => ExecuteCount++; } - /// - /// Counts how often the scheduler ran a plain action. Holding the count here lets the action overloads be - /// handed as a method group, so the callback closes over nothing. - /// + /// Counts action invocations through a method-group callback. private sealed class RunCounter { /// Gets the number of times the scheduler ran the action. diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs index 763765f9..67215fb5 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Disposables; +using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Reactive.Concurrency; namespace ReactiveUI.Primitives.Reactive.Tests; @@ -31,10 +32,10 @@ public sealed class WasmSchedulerTests /// A positive due time or period, so a null action is the only invalid argument under test. private static readonly TimeSpan ValidInterval = TimeSpan.FromMilliseconds(100); - /// A due time no test waits out, so the only run a delayed item sees is the one the test drives. + /// Due time for manually dispatched callbacks. private static readonly TimeSpan UnreachableDueTime = TimeSpan.FromHours(1); - /// A period no test waits out, so the only tick a periodic item sees is the one the test drives. + /// Period for manually dispatched callbacks. private static readonly TimeSpan UnreachablePeriod = TimeSpan.FromHours(1); /// Verifies the shared instance is a singleton. @@ -48,7 +49,8 @@ public async Task DefaultReturnsSingleton() => [Test] public async Task ScheduleRejectsNullAction() { - var scheduler = WasmScheduler.Default; + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); await Assert.That(() => scheduler.Schedule(0, null!)).ThrowsExactly(); await Assert.That(() => scheduler.Schedule(0, ValidInterval, null!)) @@ -71,14 +73,17 @@ await Assert [Test] public async Task ImmediateScheduleExecutes() { + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); TaskCompletionSource executed = new(TaskCreationOptions.RunContinuationsAsynchronously); - _ = WasmScheduler.Default.Schedule(StatePayload, (scheduler, state) => + _ = scheduler.Schedule(StatePayload, (scheduler, state) => { _ = scheduler; _ = executed.TrySetResult(state); return Disposable.Empty; }); + timeProvider.FireAll(); await Assert.That(await executed.Task).IsEqualTo(StatePayload); } @@ -88,12 +93,14 @@ public async Task ImmediateScheduleExecutes() [Test] public async Task ImmediateBurstExecutesInOrder() { + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); TaskCompletionSource done = new(TaskCreationOptions.RunContinuationsAsynchronously); List values = []; foreach (var value in ExpectedBurst) { - _ = WasmScheduler.Default.Schedule(value, (scheduler, state) => + _ = scheduler.Schedule(value, (scheduler, state) => { _ = scheduler; values.Add(state); @@ -106,8 +113,10 @@ public async Task ImmediateBurstExecutesInOrder() }); } + timeProvider.FireAll(); + _ = await done.Task; - await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default); + await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } /// Verifies an item disposed while it waits in the ready queue is skipped while later work still runs. @@ -145,19 +154,22 @@ public async Task DisposedImmediateItemIsSkipped() [Test] public async Task DelayedScheduleExecutes() { + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); TaskCompletionSource delayed = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource immediate = new(TaskCreationOptions.RunContinuationsAsynchronously); - _ = WasmScheduler.Default.Schedule(0, DelayedDueTime, (_, _) => + _ = scheduler.Schedule(0, DelayedDueTime, (_, _) => { _ = delayed.TrySetResult(true); return Disposable.Empty; }); - _ = WasmScheduler.Default.Schedule(0, TimeSpan.Zero, (_, _) => + _ = scheduler.Schedule(0, TimeSpan.Zero, (_, _) => { _ = immediate.TrySetResult(true); return Disposable.Empty; }); + timeProvider.FireAll(); await Assert.That(await delayed.Task).IsTrue(); await Assert.That(await immediate.Task).IsTrue(); @@ -168,8 +180,10 @@ public async Task DelayedScheduleExecutes() [Test] public async Task DisposedDelayedItemDoesNotRun() { + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); var ran = false; - var subscription = (WasmScheduler.StatefulWorkItem)WasmScheduler.Default.Schedule( + var subscription = (WasmScheduler.StatefulWorkItem)scheduler.Schedule( 0, UnreachableDueTime, (_, _) => @@ -192,8 +206,10 @@ public async Task DisposedDelayedItemDoesNotRun() [Test] public async Task SchedulePeriodicTicksAndStopsOnDispose() { + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); var count = 0; - var subscription = (WasmScheduler.PeriodicWorkItem)WasmScheduler.Default.SchedulePeriodic( + var subscription = (WasmScheduler.PeriodicWorkItem)scheduler.SchedulePeriodic( 0, UnreachablePeriod, state => @@ -216,18 +232,22 @@ public async Task SchedulePeriodicTicksAndStopsOnDispose() await Assert.That(count).IsEqualTo(PeriodicTickCount); } - /// Verifies a zero period is clamped instead of rejected and still ticks. + /// Verifies a zero period is clamped and dispatches ticks. /// A representing the asynchronous operation. [Test] public async Task SchedulePeriodicClampsZeroPeriod() { + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); TaskCompletionSource ticked = new(TaskCreationOptions.RunContinuationsAsynchronously); - var subscription = WasmScheduler.Default.SchedulePeriodic(0, TimeSpan.Zero, state => + var subscription = scheduler.SchedulePeriodic(0, TimeSpan.Zero, state => { _ = ticked.TrySetResult(true); return state; }); + await Assert.That(timeProvider.LastPeriod).IsEqualTo(TimeSpan.FromMilliseconds(1)); + timeProvider.FireAll(); await Assert.That(await ticked.Task).IsTrue(); subscription.Dispose(); @@ -250,8 +270,10 @@ public async Task DisposeReleasesDrainTimerAndIsIdempotent() [Test] public async Task DisposedDelayedItemDisposeIsIdempotent() { + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); var subscription = - WasmScheduler.Default.Schedule(0, UnreachableDueTime, static (_, _) => Disposable.Empty); + scheduler.Schedule(0, UnreachableDueTime, static (_, _) => Disposable.Empty); subscription.Dispose(); @@ -263,7 +285,9 @@ public async Task DisposedDelayedItemDisposeIsIdempotent() [Test] public async Task DisposedPeriodicItemDisposeIsIdempotent() { - var subscription = WasmScheduler.Default.SchedulePeriodic(0, UnreachablePeriod, static state => state); + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); + var subscription = scheduler.SchedulePeriodic(0, UnreachablePeriod, static state => state); subscription.Dispose(); @@ -278,9 +302,7 @@ public async Task SelfCancellingImmediateActionDisposesReturnedDisposable() using var scheduler = CreateIsolatedScheduler(); BooleanDisposable returned = new(); - // Build the item and publish the handle it cancels through before anything can run it. Scheduling normally - // arms the drain inside Schedule and only then returns the handle, so the action is free to run first and - // find nothing to cancel; enqueueing by hand is the same path with that window closed. + // Publish the cancellation handle before running the queued action. WasmScheduler.StatefulWorkItem? subscription = null; WasmScheduler.StatefulWorkItem item = new( scheduler, @@ -326,11 +348,7 @@ public async Task SchedulePeriodicWithNullActionThrows() => await Assert .That(static () => WasmScheduler.Default.SchedulePeriodic(0, ValidInterval, null!)) .Throws(); - /// - /// Verifies a disposed scheduler rejects new work rather than queueing work it can never drain. The drain timer - /// is released on disposal, so an accepted item would sit in the ready queue forever behind a latch the failed - /// timer post left set. Every scheduling overload fails fast instead, and none of the actions run. - /// + /// Verifies every scheduling overload rejects work after disposal. /// A representing the asynchronous operation. [Test] public async Task ScheduleAfterDisposeThrowsObjectDisposedException() @@ -390,11 +408,7 @@ public async Task DisposeCancelsWorkTheInFlightDrainHasNotReachedYet() await Assert.That(queued.Dispose).ThrowsNothing(); } - /// - /// Verifies a one-shot timer handed to a work item that was already cancelled is released instead of left armed. - /// A delayed schedule builds the item first and attaches its timer afterwards, so a dispose landing in that window - /// must not strand a timer that would still fire against an item nobody can cancel any more. - /// + /// Verifies a timer attached after cancellation is disposed immediately. /// A representing the asynchronous operation. [Test] public async Task AttachTimerReleasesATimerGivenToAnAlreadyCancelledItem() @@ -410,10 +424,8 @@ public async Task AttachTimerReleasesATimerGivenToAnAlreadyCancelledItem() return Disposable.Empty; }); - // Cancel before the delayed schedule reaches its AttachTimer call. item.Dispose(); - // Stands in for the one-shot timer, recording the release the item owes it. BooleanDisposable timer = new(); item.AttachTimer(timer); @@ -421,11 +433,7 @@ public async Task AttachTimerReleasesATimerGivenToAnAlreadyCancelledItem() await Assert.That(ran).IsEqualTo(0); } - /// - /// Verifies a periodic tick that loses the race to disposal drops the tick instead of running the action. A timer - /// callback the runtime had already dispatched when won still lands, and must - /// find the item cancelled rather than mutate state the disposal has already torn down. - /// + /// Verifies a dispatched periodic callback observes disposal before invoking the action. /// A representing the asynchronous operation. [Test] public async Task PeriodicTickThatLosesTheRaceToDisposeDoesNotRunTheAction() @@ -438,21 +446,17 @@ public async Task PeriodicTickThatLosesTheRaceToDisposeDoesNotRunTheAction() { ticks++; return state; - }); + }, + new ManualTimeProvider()); item.Dispose(); - // The period never elapses on its own, so this is the tick a callback already in flight would have delivered. item.Tick(); await Assert.That(ticks).IsEqualTo(0); } - /// - /// Verifies an enqueue that loses the race to disposal releases the item it just queued. The scheduler's disposed - /// check happens before the item joins the ready queue, so a disposal that drains the queue in between would - /// otherwise strand the item behind a drain timer that can never fire again. - /// + /// Verifies disposal between the initial check and enqueue cancels the queued item. /// A representing the asynchronous operation. [Test] public async Task EnqueueThatLosesTheRaceToDisposeReleasesTheItemItQueued() @@ -507,10 +511,136 @@ public async Task DrainDispatchesEveryQueuedItemExactlyOnce() await Assert.That(Array.FindIndex(dispatches, static count => count != 1)).IsEqualTo(NoMatch); } - /// - /// Creates a scheduler that owns its own drain timer and ready queue, so a test can dispose it without - /// disturbing the shared singleton every other test schedules through. - /// + /// Verifies a drain claim rejects stale snapshots and preserves the current claimant. + /// A task representing the asynchronous test operation. + [Test] + public async Task DrainClaimsRejectStaleStateSnapshots() + { + const int Idle = 0; + const int Running = 1; + const int Pending = 2; + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); + var runs = 0; + scheduler.QueueReady(new WasmScheduler.StatefulWorkItem(scheduler, 0, (_, _) => + { + runs++; + return Disposable.Empty; + })); + + await Assert.That(scheduler.TryPostDrain(Running)).IsFalse(); + await Assert.That(scheduler.TryPostDrain(Idle)).IsTrue(); + await Assert.That(scheduler.TryPostDrain(Idle)).IsFalse(); + await Assert.That(scheduler.TryPostDrain(Running)).IsTrue(); + await Assert.That(scheduler.TryPostDrain(Running)).IsFalse(); + await Assert.That(scheduler.TryPostDrain(Pending)).IsTrue(); + timeProvider.FireAll(); + await Assert.That(runs).IsEqualTo(1); + await Assert.That(scheduler.TryPostDrain(Idle)).IsTrue(); + } + + /// Verifies work published without a drain request is picked up by the finishing drain. + /// A task representing the asynchronous test operation. + [Test] + public async Task FinishingDrainSchedulesWorkPublishedDuringItsBatch() + { + const int First = 1; + const int Second = 2; + ManualTimeProvider timeProvider = new(); + using WasmScheduler scheduler = new(timeProvider); + List values = []; + _ = scheduler.Schedule(First, (_, value) => + { + values.Add(value); + scheduler.QueueReady(new WasmScheduler.StatefulWorkItem(scheduler, Second, (_, next) => + { + values.Add(next); + return Disposable.Empty; + })); + return Disposable.Empty; + }); + + timeProvider.FireAll(); + await Assert.That(values).IsEquivalentTo([First], EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); + timeProvider.FireAll(); + await Assert.That(values).IsEquivalentTo([First, Second], EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + /// Creates a scheduler with manually dispatched timers. /// The isolated scheduler. - private static WasmScheduler CreateIsolatedScheduler() => new(); + private static WasmScheduler CreateIsolatedScheduler() => new(new ManualTimeProvider()); + + /// Stores timer callbacks until the test dispatches them. + private sealed class ManualTimeProvider : TimeProvider + { + /// The timers owned by this provider. + private readonly List _timers = []; + + /// Gets the most recently requested timer period. + public TimeSpan LastPeriod => _timers[^1].Period; + + /// + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + ManualTimer timer = new(callback, state); + _ = timer.Change(dueTime, period); + _timers.Add(timer); + return timer; + } + + /// Invokes each armed timer once. + public void FireAll() + { + foreach (var timer in _timers.ToArray()) + { + timer.Fire(); + } + } + + /// A timer whose callback is dispatched explicitly. + /// The timer callback. + /// The callback state. + private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer + { + /// The next due time. + private TimeSpan _dueTime; + + /// Whether the timer has been disposed. + private bool _disposed; + + /// Gets the repeating period. + public TimeSpan Period { get; private set; } + + /// + public bool Change(TimeSpan dueTime, TimeSpan period) + { + _dueTime = dueTime; + Period = period; + return !_disposed; + } + + /// Invokes the callback if the timer is armed. + public void Fire() + { + if (_disposed || _dueTime == Timeout.InfiniteTimeSpan) + { + return; + } + + _dueTime = Period; + callback(state); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() => _disposed = true; + + /// + public ValueTask DisposeAsync() + { + Dispose(); + return default; + } + } + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/AsyncEnumerableSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/AsyncEnumerableSignalTests.cs index c109e3ff..cf8de3d3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/AsyncEnumerableSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/AsyncEnumerableSignalTests.cs @@ -14,82 +14,55 @@ public sealed class AsyncEnumerableSignalTests private static readonly int[] SourceValues = [1, 2, 3]; /// Verifies a value buffered while disposal tears down the observer is not delivered. - /// The test cancellation token. /// A task representing the asynchronous operation. [Test] - [Timeout(30_000)] - public async Task DisposeDuringMoveNextSuppressesBufferedOnNext(CancellationToken token) + public async Task DisposeDuringMoveNextSuppressesBufferedOnNext() { GatedAsyncEnumerable source = new(); - AsyncEnumerableSignal signal = new(source, CancellationToken.None); RecordingWitness observer = new(); - - var subscription = signal.Subscribe(observer); - - // Wait until the pump is parked inside MoveNextAsync with a value ready to emit. - await source.MoveNextEntered.Task.WaitAsync(token); - - // Dispose mid-flight, then let the buffered MoveNextAsync complete with a value. + AsyncEnumerableSignal.Subscription subscription = new(observer, source, CancellationToken.None); + var pump = subscription.PumpAsync(); + await source.MoveNextEntered.Task; subscription.Dispose(); source.ReleaseMoveNext(true); - - // Wait for the pump to drain so any (incorrect) emission would already have happened. - await source.Disposed.Task.WaitAsync(token); + await pump; await Assert.That(observer.Values).IsEmpty(); await Assert.That(observer.Completed).IsEqualTo(0); await Assert.That(observer.Errors).IsEmpty(); } - /// Verifies the enumerator is disposed exactly once when disposal races the pump. - /// The test cancellation token. + /// Disposal before the last move completes releases the enumerator once. /// A task representing the asynchronous operation. [Test] - [Timeout(30_000)] - public async Task DisposeAfterCompletionDisposesEnumeratorExactlyOnce(CancellationToken token) + public async Task DisposeBeforeCompletionDisposesEnumeratorExactlyOnce() { GatedAsyncEnumerable source = new(); - AsyncEnumerableSignal signal = new(source, CancellationToken.None); RecordingWitness observer = new(); - - var subscription = signal.Subscribe(observer); - - await source.MoveNextEntered.Task.WaitAsync(token); - - // Dispose while parked, then end the sequence; both disposal and the pump's finally run. + AsyncEnumerableSignal.Subscription subscription = new(observer, source, CancellationToken.None); + var pump = subscription.PumpAsync(); + await source.MoveNextEntered.Task; subscription.Dispose(); source.ReleaseMoveNext(false); - - await source.Disposed.Task.WaitAsync(token); - - // Dispose again to confirm the idempotent disposer never reaches the enumerator twice. + await pump; subscription.Dispose(); await Assert.That(source.DisposeCount).IsEqualTo(1); } /// Verifies disposal disposes a non-cooperative enumerator without waiting on its MoveNextAsync. - /// The test cancellation token. /// A task representing the asynchronous operation. [Test] - [Timeout(30_000)] - public async Task DisposeDisposesNonCooperativeEnumeratorWithoutHanging(CancellationToken token) + public async Task DisposeDisposesNonCooperativeEnumeratorWithoutWaitingForMoveNext() { NeverCompletingAsyncEnumerable source = new(); - AsyncEnumerableSignal signal = new(source, CancellationToken.None); RecordingWitness observer = new(); - - var subscription = signal.Subscribe(observer); - - // Wait until the pump is parked inside the MoveNextAsync that never completes. - await source.MoveNextEntered.Task.WaitAsync(token); - - // Disposal must dispose the enumerator promptly even though MoveNextAsync ignores cancellation. + AsyncEnumerableSignal.Subscription subscription = new(observer, source, CancellationToken.None); + var pump = subscription.PumpAsync(); + await source.MoveNextEntered.Task; subscription.Dispose(); - - await source.Disposed.Task.WaitAsync(token); - - // Dispose again to confirm the second call never reaches the enumerator. + await source.Disposed.Task; + await pump; subscription.Dispose(); await Assert.That(source.DisposeCount).IsEqualTo(1); @@ -99,18 +72,16 @@ public async Task DisposeDisposesNonCooperativeEnumeratorWithoutHanging(Cancella } /// Verifies a normally completing sequence delivers all values then completes. - /// The test cancellation token. /// A task representing the asynchronous operation. [Test] - [Timeout(30_000)] - public async Task CompletesNormallyAndDeliversAllValues(CancellationToken token) + public async Task CompletesNormallyAndDeliversAllValues() { AsyncEnumerableSignal signal = new(new CountingAsyncEnumerable(SourceValues), CancellationToken.None); RecordingWitness observer = new(); _ = signal.Subscribe(observer); - await observer.CompletedSignal.Task.WaitAsync(token); + await observer.CompletedSignal.Task; await Assert.That(observer.Values.SequenceEqual(SourceValues)).IsTrue(); await Assert.That(observer.Completed).IsEqualTo(1); diff --git a/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs index 41fb4d65..9a108b57 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs @@ -13,15 +13,12 @@ public class AsyncSignalTests /// Defines the integer value observed by asynchronous tests. private const int ExpectedValue = 42; - /// The first value emitted before completion in churn coverage. + /// The first value emitted before completion. private const int FirstEmittedValue = 5; /// A value emitted after completion that must be ignored. private const int IgnoredAfterCompletion = 6; - /// Defines the maximum time to wait for cross-thread test work. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// The values expected after the first emission. private static readonly int[] FirstEmittedValues = [FirstEmittedValue]; @@ -43,7 +40,8 @@ public void OnError_ArgumentChecking() => public async Task Await_Blocking() { AsyncSignal s = new(); - await GetResult_BlockingImpl(s.GetAwaiter()); + await GetResult_BlockingImpl(s); + await Assert.That(s.GetAwaiter()).IsSameReferenceAs(s); await Assert.That(s.IsCompleted).IsTrue(); } @@ -53,7 +51,8 @@ public async Task Await_Blocking() public async Task Await_Throw() { AsyncSignal s = new(); - await GetResult_Blocking_ThrowImpl(s.GetAwaiter()); + await GetResult_Blocking_ThrowImpl(s); + await Assert.That(s.GetAwaiter()).IsSameReferenceAs(s); await Assert.That(s.IsCompleted).IsTrue(); } @@ -90,25 +89,25 @@ public async Task GetResult_Blocking_Throw() /// A task representing the asynchronous operation. [Test] public async Task GetResult_Context() - { - AsyncSignal x = new(); - MyContext ctx = new(); - using ManualResetEventSlim registered = new(); - using ManualResetEventSlim completed = new(); - Thread registrationThread = new(() => +{ + AsyncSignal signal = new(); + MyContext context = new(); + var completed = false; + var previous = SynchronizationContext.Current; + try { - SynchronizationContext.SetSynchronizationContext(ctx); - var a = x.GetAwaiter(); - a.OnCompleted(() => completed.Set()); - registered.Set(); - }); - registrationThread.Start(); - await Assert.That(registered.Wait(WaitTimeout)).IsTrue(); - await Assert.That(registrationThread.Join(WaitTimeout)).IsTrue(); - x.OnNext(ExpectedValue); - x.OnCompleted(); - await Assert.That(completed.Wait(WaitTimeout)).IsTrue(); - await Assert.That(ctx.Ran).IsTrue(); + SynchronizationContext.SetSynchronizationContext(context); + signal.GetAwaiter().OnCompleted(() => completed = true); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + + signal.OnNext(ExpectedValue); + signal.OnCompleted(); + await Assert.That(completed).IsTrue(); + await Assert.That(context.Ran).IsTrue(); } /// Determines whether this instance has observers. @@ -284,79 +283,42 @@ public async Task AsyncSignalSubscriberChurnLateTerminalsAndDisposalCoverBranche await Assert.That(completionFaults).IsEqualTo(1); } - /// Gets the result blocking implementation. - /// The s. + /// A pending wait observes completion and returns its last value. + /// The pending signal. /// A task representing the asynchronous operation. - private static async Task GetResult_BlockingImpl(IAwaitSignal s) + private static async Task GetResult_BlockingImpl(AsyncSignal s) { await Assert.That(s.IsCompleted).IsFalse(); - using ManualResetEventSlim release = new(); - using ManualResetEventSlim started = new(); - Thread producer = new(() => + var waits = 0; + s.WaitIfPending(signal => { - if (!release.Wait(WaitTimeout)) - { - return; - } - - s.OnNext(ExpectedValue); - s.OnCompleted(); + waits++; + signal.OnNext(ExpectedValue); + signal.OnCompleted(); }); - var y = 0; - Thread consumer = new(() => - { - started.Set(); - y = s.GetResult(); - }); - producer.Start(); - consumer.Start(); - await Assert.That(started.Wait(WaitTimeout)).IsTrue(); - release.Set(); - await Assert.That(consumer.Join(WaitTimeout)).IsTrue(); - await Assert.That(producer.Join(WaitTimeout)).IsTrue(); - await Assert.That(y).IsEqualTo(ExpectedValue); + s.WaitIfPending(_ => waits++); + await Assert.That(waits).IsEqualTo(1); + await Assert.That(s.GetResult()).IsEqualTo(ExpectedValue); await Assert.That(s.IsCompleted).IsTrue(); } - /// Gets the result blocking throw implementation. - /// The s. + /// A pending wait observes failure and rethrows the same error. + /// The pending signal. /// A task representing the asynchronous operation. - private static async Task GetResult_Blocking_ThrowImpl(IAwaitSignal s) + private static async Task GetResult_Blocking_ThrowImpl(AsyncSignal s) { await Assert.That(s.IsCompleted).IsFalse(); - using ManualResetEventSlim release = new(); - using ManualResetEventSlim started = new(); InvalidOperationException expectedException = new(); - Thread producer = new(() => - { - if (!release.Wait(WaitTimeout)) - { - return; - } - - s.OnError(expectedException); - }); - Exception? caughtException = null; - Thread consumer = new(() => + var waits = 0; + s.WaitIfPending(signal => { - started.Set(); - try - { - _ = s.GetResult(); - } - catch (Exception exception) - { - caughtException = exception; - } + waits++; + signal.OnError(expectedException); }); - producer.Start(); - consumer.Start(); - await Assert.That(started.Wait(WaitTimeout)).IsTrue(); - release.Set(); - await Assert.That(consumer.Join(WaitTimeout)).IsTrue(); - await Assert.That(producer.Join(WaitTimeout)).IsTrue(); - await Assert.That(caughtException).IsNotNull(); - await Assert.That(caughtException!).IsSameReferenceAs(expectedException); + s.WaitIfPending(_ => waits++); + await Assert.That(waits).IsEqualTo(1); + var caughtException = Assert.Throws(() => s.GetResult()); + await Assert.That(caughtException).IsSameReferenceAs(expectedException); await Assert.That(s.IsCompleted).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs similarity index 89% rename from src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs rename to src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs index 138147b1..dcf4edd8 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Tests; /// Tests for the behavior signal type. -public class BehaviourSignalTests +public class BehaviorSignalTests { /// Initial value used by behavior signal value tests. private const int InitialValue = 42; @@ -234,40 +234,23 @@ public async Task Value_ThrowsOnDispose() await Assert.That(s.TryGetValue(out _)).IsFalse(); } - /// - /// A new subscriber that races a live must never observe a newer - /// value before the initial value it was promised; the values it receives stay monotonically ordered. - /// + /// Reentrant emission follows the initial value promised to a new subscriber. /// A task representing the asynchronous operation. [Test] - public async Task Subscribe_RacingOnNext_NeverDeliversNewerValueBeforeInitial() + public async Task Subscribe_ReentrantOnNext_FollowsTheInitialValue() { - const int subscribeAttempts = 50_000; - - BehaviorSignal signal = new(0); - using CancellationTokenSource stop = new(); - var firstFailure = default(OrderingWitness.OutOfOrderDelivery); - - var producer = Task.Run(() => + using BehaviorSignal signal = new(0); + List values = []; + using var subscription = signal.Subscribe(value => { - var value = 0; - while (!stop.IsCancellationRequested) + values.Add(value); + if (value != 0) { - value++; - signal.OnNext(value); + return; } - }); - for (var attempt = 0; attempt < subscribeAttempts && firstFailure is null; attempt++) - { - OrderingWitness witness = new(); - signal.Subscribe(witness).Dispose(); - firstFailure = witness.OutOfOrder; - } - - await stop.CancelAsync(); - await producer; - - await Assert.That(firstFailure).IsNull(); + signal.OnNext(1); + }); + await Assert.That(values.SequenceEqual([0, 1])).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs index dc290599..e236e3d2 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs @@ -282,12 +282,7 @@ public void NullArgumentsThrow() new Signal().SwitchSelect(static _ => Signal.None()).Subscribe((IObserver)null!)); } - /// - /// An observable whose subscription deliberately ignores disposal, retaining its observer so a test - /// can keep pushing notifications after the operator has switched away from it or disposed it. A - /// well-behaved source unsubscribes on either event; this misbehaving source is what the operator's - /// race guards exist to defend against. - /// + /// Retains its observer after disposal and permits further notifications. /// The element type. private sealed class ManualObservable : IObservable { diff --git a/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs index 5d99dca6..4f78e7ef 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs @@ -83,24 +83,15 @@ public async Task ConfigureAwaitPreservesTheOutcomeOfEveryExecutionShape() /// A task that completes when the continuation assertions finish. [Test] public async Task TheAwaiterResumesAContinuationScheduledThroughOnCompleted() - { - using ManualResetEventSlim release = new(false); - using CommandSignal command = new(async token => - { - await Task.Run(() => release.Wait(token), token); - return CommandResult; - }); - +{ + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CommandSignal command = new(release.Task.WaitAsync); var awaiter = command.ExecuteAsync().GetAwaiter(); TaskCompletionSource resumed = new(TaskCreationOptions.RunContinuationsAsynchronously); awaiter.OnCompleted(() => resumed.SetResult()); - await Assert.That(awaiter.IsCompleted).IsFalse(); - - release.Set(); + release.SetResult(CommandResult); await resumed.Task; - - await Assert.That(awaiter.IsCompleted).IsTrue(); await Assert.That(awaiter.GetResult()).IsEqualTo(CommandResult); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.Results.cs b/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.Results.cs new file mode 100644 index 00000000..90793f60 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.Results.cs @@ -0,0 +1,60 @@ +// 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.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests result observer updates against explicit current and stale snapshots. +public sealed partial class CommandSignalTests +{ + /// Stale additions leave the published observer set unchanged. + /// A task representing the asynchronous test. + [Test] + public async Task ResultAdditionsRetryStaleSnapshots() + { + RecordingWitness first = new(); + RecordingWitness second = new(); + RecordingWitness third = new(); + object? storage = null; + await Assert.That(CommandSignal.TryAddResult(ref storage, null, first)).IsTrue(); + await Assert.That(CommandSignal.TryAddResult(ref storage, null, second)).IsFalse(); + await Assert.That(storage).IsSameReferenceAs(first); + + await Assert.That(CommandSignal.TryAddResult(ref storage, first, second)).IsTrue(); + var pair = storage; + await Assert.That(CommandSignal.TryAddResult(ref storage, first, third)).IsFalse(); + await Assert.That(storage).IsSameReferenceAs(pair); + await Assert.That(CommandSignal.TryAddResult(ref storage, pair, third)).IsTrue(); + await Assert.That(((IObserver[])storage!).SequenceEqual([first, second, third])).IsTrue(); + await Assert.That(CommandSignal.TryAddResult(ref storage, pair, first)).IsFalse(); + } + + /// Removal retries changed snapshots and preserves absent observers without an update. + /// A task representing the asynchronous test. + [Test] + public async Task ResultRemovalsDistinguishStaleAndAbsentObservers() + { + RecordingWitness first = new(); + RecordingWitness second = new(); + RecordingWitness third = new(); + RecordingWitness absent = new(); + IObserver[] original = [first, second]; + IObserver[] current = [first, second, third]; + object? storage = current; + await Assert.That(CommandSignal.TryRemoveResult(ref storage, original, first)).IsFalse(); + await Assert.That(storage).IsSameReferenceAs(current); + await Assert.That(CommandSignal.TryRemoveResult(ref storage, current, absent)).IsTrue(); + await Assert.That(storage).IsSameReferenceAs(current); + + await Assert.That(CommandSignal.TryRemoveResult(ref storage, current, first)).IsTrue(); + await Assert.That(((IObserver[])storage!).SequenceEqual([second, third])).IsTrue(); + await Assert.That(CommandSignal.TryRemoveResult(ref storage, storage, second)).IsTrue(); + await Assert.That(storage).IsSameReferenceAs(third); + await Assert.That(CommandSignal.TryRemoveResult(ref storage, storage, third)).IsTrue(); + await Assert.That(storage).IsNull(); + await Assert.That(CommandSignal.TryRemoveResult(ref storage, null, absent)).IsTrue(); + await Assert.That(storage).IsNull(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs index 7c90a60d..b2bb0e42 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies result, failure, running-state, and disposal contracts. -public sealed class CommandSignalTests +public sealed partial class CommandSignalTests { /// Initial behavior state value used by command tests. private const int InitialStateValue = 10; @@ -19,9 +19,6 @@ public sealed class CommandSignalTests /// Successful command result. private const int CommandResult = 42; - /// Number of tasks that race for the lazily allocated stream in the contention test. - private const int ContendingTasks = 2; - /// Number of results the longest-lived result subscriber receives in the fan-out test. private const int ThreeResults = 3; @@ -117,42 +114,20 @@ public async Task CommandSignalCoversSyncFaultAndDisposalBranches() await Assert.That(disposed).IsNotNull(); } - /// - /// Reproduces the lazy-init race for : the state - /// stream is requested for the first time while an execution is finishing. If the getter - /// snapshots a flag and installs the stream after the matching - /// completion lowered it, the stream must still settle at rather than - /// latching permanently true with no in-flight execution to correct it. - /// - /// A task that completes when every interleaving has settled at false. + /// Installing a stale running snapshot reconciles it with the completed execution. + /// A task representing the asynchronous test. [Test] - public async Task IsRunningNeverLatchesTrueWhenFirstObservedDuringCompletion() - { - const int iterations = 20_000; - - for (var iteration = 0; iteration < iterations; iteration++) - { - CommandSignal command = new(static () => CommandResult); - using ManualResetEventSlim ready = new(false); - - // Race the first observation of the lazily allocated stream against the execution that - // raises and immediately lowers the running flag. - var reader = Task.Run(() => - { - ready.Wait(); - return command.IsRunning; - }); - - ready.Set(); - _ = command.ExecuteAsync(); - var stream = await reader; - - // No execution is in flight once ExecuteAsync returns for the synchronous path, so a - // stuck-true stream would have no future event to correct it. TryGetValue reads under - // the state lock, giving a synchronized view of the settled value. - _ = stream.TryGetValue(out var latched); - await Assert.That(latched).IsFalse(); - } + public async Task InstallingAStaleRunningSnapshotReconcilesCompletion() +{ + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CommandSignal command = new(release.Task.WaitAsync); + var execution = command.ExecuteAsync(); + StateSignal candidate = new(true); + release.SetResult(CommandResult); + _ = await execution; + var installed = command.InstallRunningState(candidate); + await Assert.That(installed).IsSameReferenceAs(candidate); + await Assert.That(installed.Value).IsFalse(); } /// @@ -217,34 +192,18 @@ public async Task IsRunningReportsFalseWhenObservedOnlyAfterExecution() /// A task that completes when the mid-flight assertions finish. [Test] public async Task IsRunningObservedMidFlightSettlesFalseAfterCompletion() - { - using ManualResetEventSlim release = new(false); - using ManualResetEventSlim entered = new(false); - CommandSignal command = new(async token => - { - entered.Set(); - await Task.Run(() => release.Wait(token), token); - return CommandResult; - }); - +{ + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + using CommandSignal command = new(release.Task.WaitAsync); var execution = command.ExecuteAsync(); - entered.Wait(); - - // The first observation happens while the command is genuinely running. var stream = command.IsRunning; await Assert.That(stream.Value).IsTrue(); - - release.Set(); + release.SetResult(CommandResult); _ = await execution; - await Assert.That(stream.Value).IsFalse(); } - /// - /// Results fan out to every active subscriber, and unsubscribing removes that subscriber and nobody else. - /// This walks the observer set through all three of its shapes — one observer, a pair, a longer array — and - /// back down again, because each shape has its own add and remove path. - /// + /// Results reach each active subscriber as subscriptions are added and removed. /// A task that completes when the fan-out assertions finish. [Test] public async Task ResultsFanOutToEverySubscriberAndStopAtUnsubscribe() @@ -365,66 +324,37 @@ public async Task FaultsAllocateLazilyAndDisposalReleasesTheGateSubscription() await Assert.That(running.IsDisposed).IsTrue(); } - /// - /// Forces concurrent first observations of the lazily allocated fault stream so the install CAS has a loser, - /// exercising the dispose-and-return-installed branch. All racers must observe the same instance. - /// - /// A task that completes when the concurrent fault-stream assertions finish. + /// Competing fault-stream candidates return the installed stream and dispose the unused candidate. + /// A task representing the asynchronous test. [Test] - public async Task ConcurrentFirstObservationsShareASingleFaultStream() - { - const int iterations = 5_000; - - for (var iteration = 0; iteration < iterations; iteration++) - { - CommandSignal command = new(static () => CommandResult); - using Barrier barrier = new(ContendingTasks); - - var left = Task.Run(() => - { - barrier.SignalAndWait(); - return command.Faults; - }); - var right = Task.Run(() => - { - barrier.SignalAndWait(); - return command.Faults; - }); - - var streams = await Task.WhenAll(left, right); - await Assert.That(streams[0]).IsSameReferenceAs(streams[1]); - } + public async Task CompetingFaultStreamCandidatesShareTheInstalledStream() +{ + using CommandSignal command = new(static () => CommandResult); + Signal first = new(); + Signal second = new(); + var winner = command.InstallFaultsSignal(first); + var loser = command.InstallFaultsSignal(second); + await Assert.That(winner).IsSameReferenceAs(first); + await Assert.That(loser).IsSameReferenceAs(first); + await Assert.That(command.Faults).IsSameReferenceAs(first); + await Assert.That(first.IsDisposed).IsFalse(); + await Assert.That(second.IsDisposed).IsTrue(); } - /// - /// Forces concurrent first observations of the lazily allocated stream so the install CAS has a - /// loser, exercising the dispose-and-return-installed branch. All racers must observe the same - /// instance. - /// - /// A task that completes when the concurrent-install assertions finish. + /// Competing running-stream candidates return the installed stream and dispose the unused candidate. + /// A task representing the asynchronous test. [Test] - public async Task ConcurrentFirstObservationsShareASingleStream() - { - const int iterations = 5_000; - - for (var iteration = 0; iteration < iterations; iteration++) - { - CommandSignal command = new(static () => CommandResult); - using Barrier barrier = new(ContendingTasks); - - var left = Task.Run(() => - { - barrier.SignalAndWait(); - return command.IsRunning; - }); - var right = Task.Run(() => - { - barrier.SignalAndWait(); - return command.IsRunning; - }); - - var streams = await Task.WhenAll(left, right); - await Assert.That(streams[0]).IsSameReferenceAs(streams[1]); - } + public async Task CompetingRunningStreamCandidatesShareTheInstalledStream() +{ + using CommandSignal command = new(static () => CommandResult); + StateSignal first = new(false); + StateSignal second = new(false); + var winner = command.InstallRunningState(first); + var loser = command.InstallRunningState(second); + await Assert.That(winner).IsSameReferenceAs(first); + await Assert.That(loser).IsSameReferenceAs(first); + await Assert.That(command.IsRunning).IsSameReferenceAs(first); + await Assert.That(first.IsDisposed).IsFalse(); + await Assert.That(second.IsDisposed).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs index e7e5b0db..f1b9aa55 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/AwaitableWitness.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// An observer that records every callback and hands out tasks that complete when a callback arrives, so a test -/// awaits the notification it is asserting on instead of giving a clock a budget to produce it. -/// +/// Records callbacks and completes a task for each notification. /// The type of the observed values. internal sealed class AwaitableWitness : IObserver { @@ -45,24 +42,6 @@ internal sealed class AwaitableWitness : IObserver /// Gets the number of completion callbacks observed. internal int Completions { get; private set; } - /// Gets a task that completes once the observed value count reaches a threshold. - /// The value count to wait for. - /// A task that completes when at least values have been observed. - internal Task ValueCountReaching(int count) - { - var waiter = _valueWaiters.GetOrAdd( - count, - static _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); - - // Re-check after registering: a value that arrived in between would otherwise never signal this waiter. - if (_values.Count >= count) - { - _ = waiter.TrySetResult(); - } - - return waiter.Task; - } - /// public void OnCompleted() { @@ -90,4 +69,22 @@ public void OnNext(T value) } } } + + /// Gets a task that completes once the observed value count reaches a threshold. + /// The value count to wait for. + /// A task that completes when at least values have been observed. + internal Task ValueCountReaching(int count) + { + var waiter = _valueWaiters.GetOrAdd( + count, + static _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + + // Re-check after registering: a value that arrived in between would otherwise never signal this waiter. + if (_values.Count >= count) + { + _ = waiter.TrySetResult(); + } + + return waiter.Task; + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.cs index e92862e3..1ee5903b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/CancellationTokenExtensions.cs @@ -7,21 +7,19 @@ namespace ReactiveUI.Primitives.Tests; /// Awaitable adapters over a . internal static class CancellationTokenExtensions { - /// - /// Returns a task that stays pending until the token is cancelled and then throws - /// . It models work that only ever ends by cancellation, so a test can - /// hold a factory open for as long as it needs to without handing a deadline to a clock. - /// - /// The token whose cancellation ends the wait. - /// A task that transitions to cancelled when is cancelled. - internal static async Task WhenCanceled(this CancellationToken token) + /// Provides cancellation completion for a token. + /// The token to observe. + extension(CancellationToken token) { - // No RunContinuationsAsynchronously: the awaiting body resumes inside Cancel, so the cancellation path - // has run by the time the caller's Cancel call returns. - TaskCompletionSource completion = new(); - using var registration = token.Register( - static state => ((TaskCompletionSource)state!).TrySetCanceled(), - completion); - await completion.Task.ConfigureAwait(false); + /// Completes with cancellation when the token is canceled. + /// The cancellation task. + internal async Task WhenCanceled() + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + await using var registration = token.UnsafeRegister( + static state => _ = ((TaskCompletionSource)state!).TrySetCanceled(), + completion); + await completion.Task.ConfigureAwait(false); + } } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs index 5d8af9d0..a3be5b8d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs @@ -7,17 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// A sequencer that runs the first work item inline, before Schedule returns, and queues every item -/// scheduled after that. It models a sequencer that dispatches on the calling thread when it already owns that -/// thread but defers re-entrant work, which is the interleaving under which an operator that assigns its timer -/// handle after scheduling cancels the successor timer its own callback just armed. -/// -/// The clock is virtual and only moves when a test calls , and queued work only runs when a -/// test calls . Nothing here reads the wall clock, sleeps, or starts a real timer, so the -/// tests built on it are decided entirely by the order of their own calls. -/// -/// +/// Runs the first item inline and queues subsequent work. Clock advancement and queue draining are explicit. /// The amount the clock moves forward before the first item runs. [System.Diagnostics.DebuggerDisplay("FirstInlineSequencer: Now = {Now}, Started = {_started}, Pending = {_pending.Count}")] public sealed class FirstInlineSequencer(TimeSpan advanceBeforeFirst) : ISequencer diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs index 9b74e10e..390da735 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// A sequencer that queues scheduled work instead of running it, so a test can decide exactly when a timer -/// fires. re-runs the most recently fired work item, modelling a timer that fires a -/// second time after the operator has already consumed the value it was scheduled for. -/// +/// Queues work for explicit execution and permits replaying the last callback. internal sealed class ManualSequencer : ISequencer { /// The work items scheduled and not yet run. diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/ManualTaskScheduler.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualTaskScheduler.cs new file mode 100644 index 00000000..dd24eff4 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualTaskScheduler.cs @@ -0,0 +1,30 @@ +// 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.Primitives.Tests; + +/// Runs queued tasks only when explicitly drained. +internal sealed class ManualTaskScheduler : TaskScheduler +{ + /// Tasks awaiting execution. + private readonly Queue _tasks = new(); + + /// Runs every queued task. + internal void RunPending() + { + while (_tasks.TryDequeue(out var task)) + { + _ = TryExecuteTask(task); + } + } + + /// + protected override IEnumerable GetScheduledTasks() => _tasks.ToArray(); + + /// + protected override void QueueTask(Task task) => _tasks.Enqueue(task); + + /// + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/ManualThreadPool.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualThreadPool.cs new file mode 100644 index 00000000..cac69951 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualThreadPool.cs @@ -0,0 +1,49 @@ +// 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.Runtime.CompilerServices; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Drives a thread-pool sequencer through explicit queue and clock transitions. +internal sealed class ManualThreadPool : IDisposable +{ + /// Pending immediate callbacks. + private readonly Queue _ready = new(); + + /// Initializes a new instance of the class. + internal ManualThreadPool() => + Sequencer = new(() => Timestamp, (callback, state) => _ready.Enqueue(() => callback(state)), Delays.Add); + + /// Gets the sequencer under test. + internal ThreadPoolSequencer Sequencer { get; } + + /// Gets or sets the current monotonic clock reading. + internal long Timestamp { get; set; } + + /// Gets the timer changes requested by the sequencer. + internal List Delays { get; } = []; + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() => Sequencer.Dispose(); + + /// Executes all immediate callbacks. + internal void RunReady() + { + while (_ready.TryDequeue(out var callback)) + { + callback(); + } + } + + /// Runs the timer callback at an explicit clock reading. + /// The clock reading for the callback. + internal void RunDue(long timestamp) + { + Timestamp = timestamp; + Sequencer.RunDue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/OrderingWitness.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/OrderingWitness.cs index a3ffb4cc..c113e7fa 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/OrderingWitness.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/OrderingWitness.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// An observer that watches a monotonically increasing stream and flags the first delivery that breaks the -/// ordering contract: a value that is not strictly greater than the one before it. A repeat of the previous -/// value (a duplicate) or a smaller value (a reorder) both trip the flag. -/// +/// Records the first value that is not strictly greater than its predecessor. /// The observed value type. internal sealed class OrderingWitness : IObserver where T : IComparable diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/TaskNotificationObserver.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/TaskNotificationObserver.cs new file mode 100644 index 00000000..ad2c125a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/TaskNotificationObserver.cs @@ -0,0 +1,41 @@ +// 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.Runtime.CompilerServices; + +namespace ReactiveUI.Primitives.Tests; + +/// Records notifications and signals terminal delivery. +internal sealed class TaskNotificationObserver : IObserver +{ + /// Gets the received values. + public List Values { get; } = []; + + /// Gets the received failure. + public Exception? Error { get; private set; } + + /// Gets the number of completion notifications. + public int Completions { get; private set; } + + /// Gets the terminal delivery signal. + public TaskCompletionSource Terminal { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnNext(int value) => Values.Add(value); + + /// + public void OnError(Exception error) + { + Error = error; + _ = Terminal.TrySetResult(); + } + + /// + public void OnCompleted() + { + Completions++; + _ = Terminal.TrySetResult(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/TestPolling.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/TestPolling.cs deleted file mode 100644 index 7430d027..00000000 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/TestPolling.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace ReactiveUI.Primitives.Tests; - -/// Shared polling helpers for asynchronous coverage branches. -internal static class TestPolling -{ - /// The delay in milliseconds between condition polls. - private const int PollDelayMilliseconds = 10; - - /// Polls a condition until it succeeds or the timeout elapses. - /// The condition to evaluate on each poll. - /// The maximum time to wait for the condition. - /// A task that completes when the condition is satisfied. - /// did not succeed before elapsed. - internal static async Task SpinUntil(Func condition, TimeSpan timeout) - { - var attempts = (int)(timeout.TotalMilliseconds / PollDelayMilliseconds); - for (var attempt = 0; attempt < attempts; attempt++) - { - if (condition()) - { - return; - } - - await Task.Delay(PollDelayMilliseconds).ConfigureAwait(false); - } - - throw new TimeoutException("Timed out waiting for asynchronous coverage branch."); - } -} diff --git a/src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs deleted file mode 100644 index e426d9da..00000000 --- a/src/tests/ReactiveUI.Primitives.Tests/ConcurencyTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System.Runtime.CompilerServices; -using ReactiveUI.Primitives.Concurrency; -using ReactiveUI.Primitives.Disposables; - -namespace ReactiveUI.Primitives.Tests; - -/// Tests task-pool sequencer behavior. -public class ConcurencyTests -{ - /// Defines the maximum time to wait for scheduled work in tests. - private static readonly TimeSpan ScheduleTimeout = TimeSpan.FromSeconds(5); - - /// Defines the maximum tolerated difference between sequencer and system time. - private static readonly TimeSpan ClockTolerance = TimeSpan.FromSeconds(1); - - /// Defines the short due time used by delayed scheduling tests. - private static readonly TimeSpan ShortDueTime = TimeSpan.FromMilliseconds(10); - - /// Defines the due time used by cancellation tests. - private static readonly TimeSpan CancelDueTime = TimeSpan.FromMilliseconds(200); - - /// Defines the observation window used after canceling scheduled work. - private static readonly TimeSpan CancelObservationWindow = TimeSpan.FromMilliseconds(400); - - /// Verifies that scheduling state returns a disposable. - /// A task representing the asynchronous operation. - [Test] - public async Task TestCreate() - { - var disposable = TaskPoolSequencer.Instance.Schedule(0, static (_, _) => EmptyDisposable.Instance); - await Assert.That(disposable).IsNotNull(); - disposable.Dispose(); - } - - /// Verifies that the task-pool sequencer reports current UTC time. - /// A task representing the asynchronous operation. - [Test] - public async Task TaskPoolNow() - { - var delta = TaskPoolSequencer.Instance.Now - TimeProvider.System.GetUtcNow(); - await Assert.That(delta.Duration() < ClockTolerance).IsTrue(); - } - - /// Verifies that immediate work is scheduled asynchronously. - /// A task representing the asynchronous operation. - [Test] - public async Task TaskPoolScheduleAction() - { - var nt = TaskPoolSequencer.Instance; - var probe = new InlineExecutionProbe(); - using var scheduled = nt.Schedule(probe, static p => p.RecordExecution()); - probe.MarkSchedulingFinished(); - var ranInline = await probe.Completed.Task.WaitAsync(ScheduleTimeout); - await Assert.That(ranInline).IsFalse(); - } - - /// Verifies that work due immediately is scheduled asynchronously. - /// A task representing the asynchronous operation. - [Test] - public async Task TaskPoolScheduleActionDueNow() - { - var nt = TaskPoolSequencer.Instance; - var probe = new InlineExecutionProbe(); - using var scheduled = nt.Schedule(probe, TimeSpan.Zero, static p => p.RecordExecution()); - probe.MarkSchedulingFinished(); - var ranInline = await probe.Completed.Task.WaitAsync(ScheduleTimeout); - await Assert.That(ranInline).IsFalse(); - } - - /// Verifies that delayed work is scheduled asynchronously. - /// A task representing the asynchronous operation. - [Test] - public async Task TaskPoolScheduleActionDue() - { - var nt = TaskPoolSequencer.Instance; - var probe = new InlineExecutionProbe(); - using var scheduled = nt.Schedule(probe, ShortDueTime, static p => p.RecordExecution()); - probe.MarkSchedulingFinished(); - var ranInline = await probe.Completed.Task.WaitAsync(ScheduleTimeout); - await Assert.That(ranInline).IsFalse(); - } - - /// Verifies that canceled delayed work does not run. - /// A task representing the asynchronous operation. - [Test] - public async Task TaskPoolScheduleActionCancel() - { - var nt = TaskPoolSequencer.Instance; - var probe = new CancellationProbe(); - var scheduled = nt.Schedule(probe, CancelDueTime, static p => p.RecordExecution()); - scheduled.Dispose(); - var delay = Task.Delay(CancelObservationWindow); - var observed = await Task.WhenAny(probe.Completed.Task, delay); - await Assert.That(observed).IsSameReferenceAs(delay); - await Assert.That(probe.RunCount).IsEqualTo(0); - } - - /// Verifies that delays larger than milliseconds are accepted. - /// A task representing the asynchronous operation. - [Test] - public async Task TaskPoolDelayLargerThanIntMaxValue() - { - var dueTime = TimeSpan.FromMilliseconds((double)int.MaxValue + 1); - using var scheduled = TaskPoolSequencer.Instance.Schedule(dueTime, static () => { }); - await Assert.That(scheduled).IsNotNull(); - } - - /// Records whether a scheduled callback ran on the scheduling thread before scheduling returned. - private sealed class InlineExecutionProbe - { - /// The thread the scheduling call was made on. - private readonly int _schedulingThreadId = Environment.CurrentManagedThreadId; - - /// Non-zero while the scheduling call has not yet returned. - private int _scheduling = 1; - - /// Gets the source completed with whether the callback observed an inline execution. - public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Signals that the scheduling call has returned, so any later callback cannot have run inline. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void MarkSchedulingFinished() => Volatile.Write(ref _scheduling, 0); - - /// Completes with whether this callback ran inline on the scheduling thread. - public void RecordExecution() => _ = Completed.TrySetResult( - Environment.CurrentManagedThreadId == _schedulingThreadId - && Volatile.Read(ref _scheduling) != 0); - } - - /// Records whether a scheduled callback ran at all, so a cancellation can be shown to have suppressed it. - private sealed class CancellationProbe - { - /// How many times the scheduled callback has run. - private int _runCount; - - /// Gets the source completed when the scheduled callback runs. - public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Gets the number of times the scheduled callback ran. - public int RunCount => Volatile.Read(ref _runCount); - - /// Records that the scheduled callback ran. - public void RecordExecution() - { - Volatile.Write(ref _runCount, 1); - _ = Completed.TrySetResult(1); - } - } -} diff --git a/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs index 6be8a4bd..c18c71fd 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs @@ -29,9 +29,6 @@ public sealed class ConnectableSignalTests /// Second value observed through replay. private const int SecondReplayValue = 5; - /// A replay window wide enough that no value expires while a test runs. - private const int ReplayWindowSeconds = 30; - /// Expected values for the first shared subscription. private static readonly int[] ExpectedFirstSharedValues = [FirstSharedValue]; @@ -106,7 +103,7 @@ public async Task ConnectableAliasesValidateAndConnectAtThreshold() _ = Assert.Throws(static () => ConnectableSignalExtensions.AutoShare(null!)); _ = Assert.Throws(static () => ConnectableSignalExtensions.AutoConnect(null!)); _ = Assert.Throws(() => cold.ShareLive().AutoConnect(-1)); - var replayed = cold.Replay(1, TimeSpan.FromSeconds(1)); + var replayed = cold.Replay(1, TimeSpan.MaxValue); using var connection = replayed.Connect(); source.OnNext(FirstReplayValue); List replayValues = []; @@ -155,13 +152,13 @@ public async Task UnboundedReplayLiveReplaysEveryValueToALateSubscriber() await Assert.That(late.SequenceEqual(ExpectedReplayValues)).IsTrue(); } - /// A windowed replay hub still honours its buffer-size bound. + /// A replay hub with expiration disabled honours its buffer-size bound. /// A task representing the asynchronous operation. [Test] - public async Task WindowedReplayLiveHonoursItsBufferSizeBound() + public async Task ReplayLiveWithExpirationDisabledHonoursItsBufferSizeBound() { Signal source = new(); - var replayed = source.ReplayLive(1, TimeSpan.FromSeconds(ReplayWindowSeconds)); + var replayed = source.ReplayLive(1, TimeSpan.MaxValue); using var connection = replayed.Connect(); source.OnNext(FirstReplayValue); @@ -170,7 +167,6 @@ public async Task WindowedReplayLiveHonoursItsBufferSizeBound() List late = []; using var subscription = replayed.Subscribe(late.Add); - // The window is wide enough to keep both values, so only the buffer size may trim the replay. await Assert.That(late.SequenceEqual(ExpectedReplayValues[1..])).IsTrue(); } @@ -385,45 +381,29 @@ public async Task AutoShareConnectFailureUnwindsRefcount() await Assert.That(values.SequenceEqual(ExpectedFirstSharedValues)).IsTrue(); } - /// Verifies AutoShare maintains a single connection under concurrent subscribe and dispose churn. + /// Overlapping subscriptions share one source connection until the last subscriber leaves. /// A task representing the asynchronous operation. [Test] - public async Task AutoShareKeepsSingleConnectionUnderConcurrentChurn() - { - const int Workers = 8; - const int IterationsPerWorker = 200; + public async Task AutoShareKeepsOneConnectionAcrossOverlappingSubscriptions() +{ var peakConnections = 0; var liveConnections = 0; var cold = Signal.Create(observer => { - var live = Interlocked.Increment(ref liveConnections); - var peak = Volatile.Read(ref peakConnections); - while (live > peak && Interlocked.CompareExchange(ref peakConnections, live, peak) != peak) - { - peak = Volatile.Read(ref peakConnections); - } - + liveConnections++; + peakConnections = Math.Max(peakConnections, liveConnections); observer.OnNext(FirstSharedValue); - return new ActionDisposable(() => Interlocked.Decrement(ref liveConnections)); + return new ActionDisposable(() => liveConnections--); }); - var shared = cold.Share().AutoShare(); - - var workers = new Task[Workers]; - for (var worker = 0; worker < Workers; worker++) - { - workers[worker] = Task.Run(() => - { - for (var iteration = 0; iteration < IterationsPerWorker; iteration++) - { - shared.Subscribe(static _ => { }).Dispose(); - } - }); - } - - await Task.WhenAll(workers); - - // Refcount churn must never run two upstream connections at once and must release the last one. + var first = shared.Subscribe(static _ => { }); + var second = shared.Subscribe(static _ => { }); + first.Dispose(); + await Assert.That(liveConnections).IsEqualTo(1); + var third = shared.Subscribe(static _ => { }); + second.Dispose(); + await Assert.That(liveConnections).IsEqualTo(1); + third.Dispose(); await Assert.That(peakConnections).IsEqualTo(1); await Assert.That(liveConnections).IsEqualTo(0); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerBaseTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerStateTests.cs similarity index 87% rename from src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerBaseTests.cs rename to src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerStateTests.cs index a4910fb2..79e72b09 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerBaseTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerStateTests.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Tests; /// Tests for the shared UI dispatch sequencer base. -public sealed class DispatchSequencerBaseTests +public sealed class DispatchSequencerStateTests { /// Expected post count after reentrant scheduling. private const int ExpectedReentrantPostCount = 2; @@ -51,24 +51,10 @@ public sealed class DispatchSequencerBaseTests /// The single value recorded when only the live half of a pair of work items runs. private static readonly int[] ExpectedLiveOnly = [OuterDrainValue]; - /// How far ahead delayed work is scheduled. - private static readonly TimeSpan DelayedDueTime = TimeSpan.FromMilliseconds(100); - - /// - /// A due time far enough out that no scheduling pause between capturing the timestamp and reading the - /// remaining delay can elapse it. Asserting a still-pending delay against a short due time races the wall - /// clock: a loaded runner can spend longer than the due time inside the preceding assertion, leaving nothing - /// to wait for and clamping the result to zero. - /// - private static readonly TimeSpan UnreachableDueTime = TimeSpan.FromHours(1); - - /// How long a test watches for delayed work that must never reach the dispatcher. - private static readonly TimeSpan CancelObservationWindow = TimeSpan.FromMilliseconds(400); - /// Verifies a burst posts one dispatcher drain and preserves FIFO order. /// A task representing the asynchronous operation. [Test] - public async Task DispatchSequencerBaseCoalescesBurstIntoOneDrain() + public async Task CoalescesBurstIntoOneDrain() { var sequencer = TestDispatchSequencer.Create(); List values = []; @@ -84,7 +70,7 @@ public async Task DispatchSequencerBaseCoalescesBurstIntoOneDrain() /// Verifies cancelled queued work is skipped when the drain runs. /// A task representing the asynchronous operation. [Test] - public async Task DispatchSequencerBaseSkipsCancelledQueuedWork() + public async Task SkipsCancelledQueuedWork() { var sequencer = TestDispatchSequencer.Create(); List values = []; @@ -98,7 +84,7 @@ public async Task DispatchSequencerBaseSkipsCancelledQueuedWork() /// Verifies work scheduled from inside a drain runs in the next drain. /// A task representing the asynchronous operation. [Test] - public async Task DispatchSequencerBaseDefersReentrantWorkToNextDrain() + public async Task DefersReentrantWorkToNextDrain() { var sequencer = TestDispatchSequencer.Create(); List values = []; @@ -142,12 +128,45 @@ public async Task RunIfActiveRunsLiveWorkAndSkipsCancelledWork() /// A task representing the asynchronous operation. [Test] public async Task DelayUntilClampsElapsedDueTimestampsToZero() +{ + await Assert.That(DispatchSequencerState.DelayUntil(0)).IsEqualTo(TimeSpan.Zero); + await Assert.That(Sequencer.TimeUntil(ElapsedTimestampOffset, ElapsedTimestampOffset)).IsEqualTo(TimeSpan.Zero); + await Assert.That(Sequencer.TimeUntil(0, ElapsedTimestampOffset)).IsEqualTo(TimeSpan.Zero); + await Assert.That(Sequencer.TimeUntil(ElapsedTimestampOffset, 0)).IsGreaterThan(TimeSpan.Zero); + } + + /// Active delayed work returns to its owning dispatch queue. + /// A task representing the asynchronous test. + [Test] + public async Task MarshalOnDueQueuesActiveWorkOnItsOwner() { - var now = DispatchSequencerState.Timestamp; + var sequencer = TestDispatchSequencer.Create(); + List values = []; + RecordingWorkItem item = new(values, OuterDrainValue); + DispatchSequencerState.MarshalOnDueWorkItem marshal = new(sequencer, item); + marshal.Execute(); + await Assert.That(values.Count).IsEqualTo(0); + sequencer.RunNextDrain(); + await Assert.That(values.SequenceEqual([OuterDrainValue])).IsTrue(); + } - await Assert.That(DispatchSequencerState.DelayUntil(now - ElapsedTimestampOffset)).IsEqualTo(TimeSpan.Zero); - await Assert.That(DispatchSequencerState.DelayUntil(Sequencer.AddTimestamp(now, UnreachableDueTime)) > TimeSpan.Zero) - .IsTrue(); + /// The fallback timer marshals delayed work through the owning dispatch queue. + /// A task representing the asynchronous test. + [Test] + public async Task FallbackTimerMarshalsDelayedWorkToItsOwner() + { + ManualSequencer timer = new(); + var sequencer = TestDispatchSequencer.Create(timer); + List values = []; + RecordingWorkItem item = new(values, OuterDrainValue); + sequencer.Schedule(item, long.MaxValue); + await Assert.That(values.Count).IsEqualTo(0); + await Assert.That(sequencer.PostCount).IsEqualTo(0); + timer.RunPending(); + await Assert.That(values.Count).IsEqualTo(0); + await Assert.That(sequencer.PostCount).IsEqualTo(1); + sequencer.RunNextDrain(); + await Assert.That(values.SequenceEqual([OuterDrainValue])).IsTrue(); } /// Verifies a platform delayed-scheduling override receives delayed work instead of the shared timer. @@ -158,7 +177,7 @@ public async Task DelayedWorkUsesThePlatformOverrideWhenOneIsSupplied() var sequencer = ConfigurableDispatchSequencer.CreateWithDelayedOverride(); List values = []; RecordingWorkItem item = new(values, OuterDrainValue); - var dueTimestamp = Sequencer.AddTimestamp(sequencer.Timestamp, DelayedDueTime); + const long dueTimestamp = long.MaxValue; sequencer.Schedule(item, dueTimestamp); @@ -173,16 +192,13 @@ public async Task DelayedWorkUsesThePlatformOverrideWhenOneIsSupplied() /// A task representing the asynchronous operation. [Test] public async Task DelayedWorkCancelledBeforeItIsDueNeverReachesTheDispatcher() - { +{ var sequencer = ConfigurableDispatchSequencer.Create(); List values = []; RecordingWorkItem item = new(values, OuterDrainValue); - - sequencer.Schedule(item, Sequencer.AddTimestamp(sequencer.Timestamp, DelayedDueTime)); + DispatchSequencerState.MarshalOnDueWorkItem marshal = new(sequencer, item); item.Dispose(); - - await Task.Delay(CancelObservationWindow); - + marshal.Execute(); await Assert.That(sequencer.PostCount).IsEqualTo(0); await Assert.That(values.Count).IsEqualTo(0); } @@ -280,11 +296,14 @@ private TestDispatchSequencer() /// Creates a sequencer whose dispatch state is wired only after construction has finished, /// so the engine never sees a half-built owner. + /// The optional fallback delay sequencer. /// The wired sequencer. - public static TestDispatchSequencer Create() + public static TestDispatchSequencer Create(ISequencer? sharedTimer = null) { TestDispatchSequencer sequencer = new(); - sequencer._state = new(sequencer, sequencer.Post, sequencer.RunDrain); + sequencer._state = sharedTimer is null + ? new(sequencer, sequencer.Post, sequencer.RunDrain) + : new(sequencer, sequencer.Post, sequencer.RunDrain, null, sharedTimer); return sequencer; } @@ -337,10 +356,7 @@ private sealed class RecordingWorkItem(List values, int value) : IWorkItem, public void Execute() => _values.Add(_value); } - /// - /// Dispatch sequencer whose post can be made to reject a drain or throw, and which can capture delayed work - /// through a platform override instead of falling back to the shared thread-pool timer. - /// + /// Captures delayed work and supports accepted, rejected, or throwing drain posts. private sealed class ConfigurableDispatchSequencer : ISequencer { /// Posted drains awaiting a run. diff --git a/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs index e5e3a4f4..b879f1b3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs @@ -34,7 +34,7 @@ public class DisposableTests /// The number of disposables added to the multiple-disposable group. private const int Twenty = 20; - /// Calendar year used by value-type timestamp coverage. + /// Calendar year used by timestamp values. private const int CalendarYear = 2024; /// The disposal count produced when a slot disposes twice. @@ -175,7 +175,7 @@ public async Task LowLevelDisposablesCollectionsAndSchedulersCoverDeterministicE _ = Assert.Throws(() => current.Schedule((Action)null!)); _ = Assert.Throws(() => current.Schedule(One, TimeSpan.Zero, null!)); List scheduled = []; - current.Schedule(One, TimeSpan.FromMilliseconds(1), (_, state) => + current.Schedule(One, TimeSpan.Zero, (_, state) => { scheduled.Add(state); return EmptyDisposable.Instance; @@ -368,7 +368,7 @@ private static async Task InvokeInternalHandleMembers(Exception exception) _ = Assert.Throws(() => Handle.Throw(exception, 1, Two, Three)); } - /// Exposes the protected dispose path for coverage. + /// Exposes disposal without managed-resource cleanup. private sealed class ExposedSingleDisposable : SingleDisposable { /// Initializes a new instance of the class. @@ -383,7 +383,7 @@ public ExposedSingleDisposable(IDisposable disposable) public void DisposeFalse() => Dispose(false); } - /// Exposes the protected dispose path for coverage. + /// Exposes disposal without managed-resource cleanup. private sealed class ExposedSingleReplaceableDisposable : SingleReplaceableDisposable { /// Initializes a new instance of the class. @@ -398,7 +398,7 @@ public ExposedSingleReplaceableDisposable(IDisposable disposable) public void DisposeFalse() => Dispose(false); } - /// Exposes the protected dispose path for coverage. + /// Exposes disposal without managed-resource cleanup. private sealed class ExposedMultipleDisposable : MultipleDisposable { /// Initializes a new instance of the class. diff --git a/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs index 67b023c5..bcf39bb5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs @@ -3,21 +3,12 @@ // See the LICENSE file in the project root for full license information. using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Signals; namespace ReactiveUI.Primitives.Tests; -/// -/// Tests for the recurring Every timer on the current-thread sequencer, whose trampoline runs the -/// ticks on the subscribing thread and therefore has to hand the subscription back before it starts ticking. -/// Every bounding operator layered over it — Take, TakeWhile, TakeUntil, Any, -/// All, Contains, IsEmpty, Expire — has to enter the trampoline itself, so that the -/// source only queues its first tick and the sink owns the upstream handle in time to dispose it when the bound -/// is reached. An operator that skips that step drains the trampoline from inside the source's own subscribe -/// call and never learns its bound was hit, livelocking the subscribing thread. -/// +/// Tests recurring ticks and bounded subscriptions on the current-thread sequencer. public sealed class EverySignalTests { /// The number of ticks the bounded subscriptions ask for. @@ -32,29 +23,10 @@ public sealed class EverySignalTests /// The period between ticks. private static readonly TimeSpan TickPeriod = TimeSpan.FromMilliseconds(10); - /// - /// A tick period an order of magnitude longer than , so the inactivity timeout always - /// fires first, yet short enough that the trampoline's wait for the tick that never arrives stays inside - /// . - /// - private static readonly TimeSpan QuietTickPeriod = TimeSpan.FromSeconds(1); - /// The inactivity window Expire allows before it times the sequence out. private static readonly TimeSpan ExpiryPeriod = TimeSpan.FromMilliseconds(50); - /// - /// The time a subscribing thread is given to return before it is declared livelocked. A livelocked trampoline - /// never returns, so the window costs nothing to widen and is deliberately far longer than any of these - /// subscriptions needs: it only has to outlast a runner busy enough to stall a thread that is merely slow. - /// - private static readonly TimeSpan LivelockTimeout = TimeSpan.FromSeconds(30); - - /// - /// Verifies the recurring schedule survives a sequencer that runs the first tick before its own - /// Schedule returns. That tick arms the next one, and the handle the outer call goes on to return - /// must not replace - and so cancel - the successor the tick just armed, which would stop the interval - /// after a single value. - /// + /// An inline first tick retains the successor it schedules before the initial scheduling call returns. /// A task representing the asynchronous operation. [Test] public async Task EveryRetainsTheTickArmedByAnInlineFirstTick() @@ -77,15 +49,9 @@ public async Task EveryOnTheCurrentThreadSequencerStopsWhenTakeReachesItsCount() { List ticks = []; var completions = 0; - - var subscriber = RunOnDedicatedThread(() => - { - using var subscription = Signal.Every(TickPeriod, Sequencer.CurrentThread) - .Take(RequestedTicks) - .Subscribe(ticks.Add, static _ => { }, () => completions++); - }); - - await Assert.That(CompletedWithinTimeout(subscriber)).IsTrue(); + using var subscription = Signal.Every(TimeSpan.Zero, Sequencer.CurrentThread) + .Take(RequestedTicks) + .Subscribe(ticks.Add, static _ => { }, () => completions++); await Assert.That(ticks.SequenceEqual(ExpectedTicks)).IsTrue(); await Assert.That(completions).IsEqualTo(1); } @@ -96,17 +62,10 @@ public async Task EveryOnTheCurrentThreadSequencerStopsWhenTakeReachesItsCount() public async Task EveryOnTheCurrentThreadSequencerTicksOnTheSubscribingThread() { List tickThreadIds = []; - var subscriberThreadId = 0; - - var subscriber = RunOnDedicatedThread(() => - { - subscriberThreadId = Environment.CurrentManagedThreadId; - using var subscription = Signal.Every(TickPeriod, Sequencer.CurrentThread) - .Take(RequestedTicks) - .Subscribe(_ => tickThreadIds.Add(Environment.CurrentManagedThreadId)); - }); - - await Assert.That(CompletedWithinTimeout(subscriber)).IsTrue(); + var subscriberThreadId = Environment.CurrentManagedThreadId; + using var subscription = Signal.Every(TimeSpan.Zero, Sequencer.CurrentThread) + .Take(RequestedTicks) + .Subscribe(_ => tickThreadIds.Add(Environment.CurrentManagedThreadId)); await Assert.That(tickThreadIds.Count).IsEqualTo(RequestedTicks); await Assert.That(tickThreadIds.TrueForAll(id => id == subscriberThreadId)).IsTrue(); } @@ -119,24 +78,17 @@ public async Task EveryOnTheCurrentThreadSequencerStopsWhenTakeWhileRejectsATick List ticks = []; List tickThreadIds = []; var completions = 0; - var subscriberThreadId = 0; - - var subscriber = RunOnDedicatedThread(() => - { - subscriberThreadId = Environment.CurrentManagedThreadId; - using var subscription = Signal.Every(TickPeriod, Sequencer.CurrentThread) - .TakeWhile(static tick => tick < RequestedTicks) - .Subscribe( - tick => - { - ticks.Add(tick); - tickThreadIds.Add(Environment.CurrentManagedThreadId); - }, - static _ => { }, - () => completions++); - }); - - await Assert.That(CompletedWithinTimeout(subscriber)).IsTrue(); + var subscriberThreadId = Environment.CurrentManagedThreadId; + using var subscription = Signal.Every(TimeSpan.Zero, Sequencer.CurrentThread) + .TakeWhile(static tick => tick < RequestedTicks) + .Subscribe( + tick => + { + ticks.Add(tick); + tickThreadIds.Add(Environment.CurrentManagedThreadId); + }, + static _ => { }, + () => completions++); await Assert.That(ticks.SequenceEqual(ExpectedTicks)).IsTrue(); await Assert.That(completions).IsEqualTo(1); await Assert.That(tickThreadIds.TrueForAll(id => id == subscriberThreadId)).IsTrue(); @@ -152,29 +104,23 @@ public async Task EveryOnTheCurrentThreadSequencerStopsWhenTakeUntilIsNotified() var completions = 0; var subscriberThreadId = 0; using CancellationTokenSource stop = new(); - - var subscriber = RunOnDedicatedThread(() => - { - subscriberThreadId = Environment.CurrentManagedThreadId; - using var subscription = Signal.Every(TickPeriod, Sequencer.CurrentThread) - .TakeUntil(stop.Token) - .Subscribe( - tick => + subscriberThreadId = Environment.CurrentManagedThreadId; + using var subscription = Signal.Every(TimeSpan.Zero, Sequencer.CurrentThread) + .TakeUntil(stop.Token) + .Subscribe( + tick => + { + ticks.Add(tick); + tickThreadIds.Add(Environment.CurrentManagedThreadId); + if (ticks.Count != RequestedTicks) { - ticks.Add(tick); - tickThreadIds.Add(Environment.CurrentManagedThreadId); - if (ticks.Count != RequestedTicks) - { - return; - } + return; + } - stop.Cancel(); - }, - static _ => { }, - () => completions++); - }); - - await Assert.That(CompletedWithinTimeout(subscriber)).IsTrue(); + stop.Cancel(); + }, + static _ => { }, + () => completions++); await Assert.That(ticks.SequenceEqual(ExpectedTicks)).IsTrue(); await Assert.That(completions).IsEqualTo(1); await Assert.That(tickThreadIds.TrueForAll(id => id == subscriberThreadId)).IsTrue(); @@ -225,36 +171,23 @@ public Task EveryOnTheCurrentThreadSequencerStopsWhenIsEmptySeesItsFirstTick() = static source => source.IsEmpty(), expectedResult: false); - /// - /// Verifies Expire times a silent current-thread source out instead of livelocking on it. The tick period - /// is far longer than the expiry window, so the inactivity timeout is guaranteed to fire before the first tick. - /// + /// Advancing to the expiry boundary terminates the source before its first scheduled tick. /// A task representing the asynchronous operation. [Test] - public async Task EveryOnTheCurrentThreadSequencerExpiresWhenNoTickArrivesInTime() - { + public async Task EveryExpiresBeforeTheFirstVirtualTick() +{ + VirtualClock clock = new(); var ticks = 0; Exception? failure = null; - - var subscriber = RunOnDedicatedThread(() => - { - using var subscription = Signal.Every(QuietTickPeriod, Sequencer.CurrentThread) - .Expire(ExpiryPeriod) - .Subscribe( - _ => ticks++, - error => failure = error, - static () => { }); - }); - - await Assert.That(CompletedWithinTimeout(subscriber)).IsTrue(); + using var subscription = Signal.Every(ExpiryPeriod + ExpiryPeriod, clock) + .Expire(ExpiryPeriod, clock) + .Subscribe(_ => ticks++, error => failure = error, static () => { }); + clock.AdvanceBy(ExpiryPeriod); await Assert.That(ticks).IsEqualTo(0); await Assert.That(failure).IsTypeOf(); } - /// - /// Asserts a boolean bounding operator terminates the current-thread ticks at the first tick that satisfies it, - /// and that it hands its single result back on the subscribing thread the trampoline ticks on. - /// + /// Checks the bounding result and completion on the subscribing thread. /// Applies the bounding operator to the current-thread tick source. /// The value the bounded sequence must emit before it completes. /// A task representing the asynchronous operation. @@ -265,105 +198,18 @@ private static async Task AssertBoundedByFirstMatchingTick( List results = []; List resultThreadIds = []; var completions = 0; - var subscriberThreadId = 0; - - var subscriber = RunOnDedicatedThread(() => - { - subscriberThreadId = Environment.CurrentManagedThreadId; - using var subscription = bound(Signal.Every(TickPeriod, Sequencer.CurrentThread)) - .Subscribe( - result => - { - results.Add(result); - resultThreadIds.Add(Environment.CurrentManagedThreadId); - }, - static _ => { }, - () => completions++); - }); - - await Assert.That(CompletedWithinTimeout(subscriber)).IsTrue(); + var subscriberThreadId = Environment.CurrentManagedThreadId; + using var subscription = bound(Signal.Every(TimeSpan.Zero, Sequencer.CurrentThread)) + .Subscribe( + result => + { + results.Add(result); + resultThreadIds.Add(Environment.CurrentManagedThreadId); + }, + static _ => { }, + () => completions++); await Assert.That(results).IsEquivalentTo([expectedResult], EqualityComparer.Default); await Assert.That(completions).IsEqualTo(1); await Assert.That(resultThreadIds.TrueForAll(id => id == subscriberThreadId)).IsTrue(); } - - /// Runs the subscription body on its own background thread so a livelock cannot hang the test host. - /// The subscription body to run. - /// The subscribing thread, already running the body. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static SubscribingThread RunOnDedicatedThread(Action body) => SubscribingThread.Start(body); - - /// Waits for the subscribing thread to finish within the livelock timeout. - /// The subscribing thread to wait on. - /// when the subscribing thread finished in time. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool CompletedWithinTimeout(SubscribingThread subscriber) => - subscriber.ReturnedWithin(LivelockTimeout); - - /// The thread a subscription body runs on, and whatever that body threw. - /// - /// The livelock guard waits on the thread itself rather than on a task the thread completes. Completing a task - /// only queues its continuation to the thread pool, so the guard would be racing two pool-scheduled - /// continuations: on a runner whose pool is saturated the subscribing thread's continuation can be dequeued - /// after the guard's window has already closed, reporting a livelock in a trampoline that in truth returned in - /// milliseconds. is an OS-level wait no amount of pool pressure can starve, - /// so it observes the subscribing thread returning rather than the pool getting round to saying that it did. - /// A thread that is genuinely livelocked never returns, so the guard still trips on it. - /// - private sealed class SubscribingThread - { - /// The subscription body to run. - private readonly Action _body; - - /// The background thread the body runs on. - private readonly Thread _thread; - - /// The exception the body threw, if any. - private ExceptionDispatchInfo? _failure; - - /// Initializes a new instance of the class. - /// The subscription body to run. - private SubscribingThread(Action body) - { - _body = body; - _thread = new(Run) { IsBackground = true, }; - } - - /// Starts a subscription body on a background thread of its own. - /// The subscription body to run. - /// The running subscribing thread. - internal static SubscribingThread Start(Action body) - { - SubscribingThread subscriber = new(body); - subscriber._thread.Start(); - return subscriber; - } - - /// Waits for the thread to return, rethrowing whatever the subscription body threw. - /// How long the thread is given to return before it is declared livelocked. - /// when the thread returned within . - internal bool ReturnedWithin(TimeSpan timeout) - { - if (!_thread.Join(timeout)) - { - return false; - } - - _failure?.Throw(); - return true; - } - - /// Runs the subscription body, capturing a failure so it can be rethrown with its original stack. - private void Run() - { - try - { - _body(); - } - catch (Exception error) - { - _failure = ExceptionDispatchInfo.Capture(error); - } - } - } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs index 27488f5b..d0edd33e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs @@ -115,13 +115,7 @@ public async Task NoTimeoutFiresAfterError() await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); } - /// - /// Verifies a value that arrives after the inactivity window closed expires the sequence even though the armed - /// timeout has not been dispatched yet. The timeout runs on the sequencer, and a thread-pool sequencer whose pool - /// is saturated can dispatch it arbitrarily late while a source ticking on its own thread keeps producing. The - /// window is a property of the clock, not of whether the timer callback has been given a thread, so a value that - /// missed it must not reach the observer. - /// + /// A value after the inactivity deadline expires the sequence even when the timeout callback remains queued. /// A task representing the asynchronous operation. [Test] public async Task ValueArrivingAfterTheWindowClosedExpiresWhileTheTimeoutIsStillUndispatched() @@ -141,10 +135,7 @@ public async Task ValueArrivingAfterTheWindowClosedExpiresWhileTheTimeoutIsStill await Assert.That(errors.SequenceEqual([nameof(TimeoutException)])).IsTrue(); } - /// - /// Verifies the deadline check does not expire a value that is still inside its window. This is the guard against - /// the previous test's fix over-firing: an undispatched timeout must not turn an on-time value into a timeout. - /// + /// A value inside the inactivity window is forwarded while its timeout callback remains queued. /// A task representing the asynchronous operation. [Test] public async Task ValueArrivingInsideTheWindowIsForwardedWhileTheTimeoutIsStillUndispatched() @@ -180,11 +171,7 @@ public async Task TimeoutDoesNotEnterObserverWhileOnNextIsInFlight() await Assert.That(observer.Values).IsEqualTo(One); } - /// - /// A sequencer that accepts scheduled work and never dispatches it, modelling a thread-pool sequencer whose pool - /// is saturated: the timer becomes due on the clock, but no thread is free to run the callback. Its clock is - /// driven by the test. - /// + /// Tracks virtual time and accepts work without dispatching it. /// The instant the clock starts at. private sealed class UndispatchedSequencer(DateTimeOffset start) : ISequencer { diff --git a/src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs new file mode 100644 index 00000000..f1d3ab39 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Advanced; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Verifies observer failures during task result delivery. +public class FromAsyncTaskObservationTests +{ + /// The task result passed to the observer. + private const int ResultValue = 7; + + /// A throwing value callback receives no additional terminal notification. + /// Whether the observer disposes its lifetime before throwing. + /// The asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ThrowingValueCallbackSuppressesTerminalNotification(bool dispose) + { + using AsyncSubscriptionLifetime lifetime = new(); + FailingObserver observer = new(lifetime, dispose); + using FromAsyncExternalCancellation cancellation = new(observer, lifetime, CancellationToken.None); + FromAsyncTaskObservation observation = new(observer, lifetime, cancellation, null); + observation.Observe(Task.FromResult(ResultValue)); + await Assert.That(observer.Values).IsEqualTo(1); + await Assert.That(observer.Errors).IsEqualTo(0); + await Assert.That(observer.Completions).IsEqualTo(0); + await Assert.That(lifetime.IsCompleted).IsTrue(); + } + + /// An uncancelable external token permits synchronous factory execution. + /// The asynchronous test. + [Test] + public async Task UncancelableTokenRunsFactory() + { + TaskNotificationObserver observer = new(); + using var subscription = Signal.FromAsync(static _ => Task.FromResult(ResultValue), CancellationToken.None).Subscribe(observer); + await Assert.That(observer.Values.SequenceEqual([ResultValue])).IsTrue(); + await Assert.That(observer.Completions).IsEqualTo(1); + await Assert.That(observer.Error).IsNull(); + } + + /// Throws after recording a value and optionally disposing its lifetime. + /// The subscription lifetime. + /// Whether to dispose before throwing. + private sealed class FailingObserver(AsyncSubscriptionLifetime lifetime, bool dispose) : IObserver + { + /// Gets the number of value callbacks. + public int Values { get; private set; } + + /// Gets the number of error callbacks. + public int Errors { get; private set; } + + /// Gets the number of completion callbacks. + public int Completions { get; private set; } + + /// + public void OnNext(int value) + { + Values++; + if (dispose) + { + lifetime.Dispose(); + } + + throw new InvalidOperationException("observer failure"); + } + + /// + public void OnError(Exception error) => Errors++; + + /// + public void OnCompleted() => Completions++; + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs index 62fb7687..eddcd39f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs @@ -22,28 +22,16 @@ public sealed class LoopSignalTests /// The values a three-repetition loop must observe. private static readonly int[] ExpectedValues = [RepeatedValue, RepeatedValue, RepeatedValue]; - /// How long the bounded loop is given to finish before it is declared livelocked. - private static readonly TimeSpan CompletionTimeout = TimeSpan.FromSeconds(30); - /// Verifies a loop bounded by Take repeats the value exactly the requested number of times and stops. /// A task representing the asynchronous operation. [Test] public async Task LoopBoundedByTakeRepeatsTheValueAndStops() - { +{ List values = []; var completions = 0; - - // The loop runs its ticks on the subscribing thread's trampoline, so subscribe on a dedicated thread: - // a bounded loop returns in milliseconds, but a regression that livelocked it would otherwise hang the run. - var worker = Task.Run(() => - { - using var subscription = Signal.Loop(RepeatedValue) - .Take(RequestedRepetitions) - .Subscribe(values.Add, static _ => { }, () => completions++); - }); - - await Assert.That(await Task.WhenAny(worker, Task.Delay(CompletionTimeout)) == worker).IsTrue(); - await worker; + using var subscription = Signal.Loop(RepeatedValue) + .Take(RequestedRepetitions) + .Subscribe(values.Add, static _ => { }, () => completions++); await Assert.That(values.SequenceEqual(ExpectedValues)).IsTrue(); await Assert.That(completions).IsEqualTo(1); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/MergeCoordinatorTests.cs b/src/tests/ReactiveUI.Primitives.Tests/MergeCoordinatorTests.cs new file mode 100644 index 00000000..6e756326 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/MergeCoordinatorTests.cs @@ -0,0 +1,33 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests merge completion tracking across inner sources. +public sealed class MergeCoordinatorTests +{ + /// Repeated completion cannot consume another inner source's active count. + /// A task representing the asynchronous test. + [Test] + public async Task RepeatedInnerCompletionKeepsTheSiblingActive() + { + IObserver? left = null; + IObserver? right = null; + RecordingWitness downstream = new(); + ScriptedObservable leftSource = new(observer => left = observer); + ScriptedObservable rightSource = new(observer => right = observer); + + using var subscription = new MergeCoordinator(downstream).Run([leftSource, rightSource]); + + left!.OnCompleted(); + left.OnCompleted(); + await Assert.That(downstream.Completed).IsEqualTo(0); + + right!.OnCompleted(); + await Assert.That(downstream.Completed).IsEqualTo(1); + await Assert.That(right).IsNotNull(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs index 0b304818..4fd86be2 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Tests; -/// Focused coverage for the Rx-style on-error resume sequence coordinator. +/// Tests continuation after source errors. public sealed class OnErrorResumeNextSignalTests { /// The integer constant one. diff --git a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs index 0ea73d93..1e680f78 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs @@ -30,36 +30,21 @@ public sealed class PrioritySemaphoreSignalTests /// The number of values expected after the first drain. private const int FirstDrainCount = 2; - /// The number of worker tasks used to stress concurrent signaling. - private const int StressWorkers = 4; + /// The number of reentrant operation groups. + private const int OperationGroups = 4; - /// The number of operations each stress worker performs. - private const int StressIterations = 4; + /// The number of release and production pairs per group. + private const int OperationsPerGroup = 4; - /// The number of seeded values for the concurrent drain scenario. + /// The number of values queued before draining. private const int SeededValueCount = 12; - /// The initial semaphore capacity used in the concurrent drain scenario. + /// The initial capacity used to drain queued values. private const int InitialDrainCapacity = 3; /// The amount added during alternating capacity updates. private const int CapacityJitter = 2; - /// The number of polling loops while waiting for signal drain. - private const int PollIterations = 500; - - /// The wait duration for each drain polling loop. - private const int PollDelayMilliseconds = 1; - - /// Task-group offsets for the concurrent operations phase. - private const int OnNextTaskOffsetMultiplier = 2; - - /// The number of worker groups (release, capacity, and OnNext) started for each stress worker. - private const int StressWorkerGroups = 3; - - /// The wait duration for terminal serialization probes. - private static readonly TimeSpan TerminalProbeTimeout = TimeSpan.FromSeconds(5); - /// Constructor and observer validation follow the inner signal contract. /// A task representing the asynchronous operation. [Test] @@ -203,135 +188,115 @@ public async Task ScheduledInnerSignalDeliversThroughSequencerAndDisposes() await Assert.That(observer.Values.SequenceEqual([FourthValue])).IsTrue(); } - /// Concurrent release and production drain work without concurrent downstream calls. + /// Reentrant release and production preserve serialized value delivery. /// A task representing the asynchronous operation. [Test] - public async Task ConcurrentOperationsDoNotNotifyObserverConcurrently() - { - using var signal = new PrioritySemaphoreSignal(InitialDrainCapacity); - var observer = new ConcurrencyProbe(); - var subscription = signal.Subscribe(observer); - for (var i = 0; i < SeededValueCount; i++) - { - signal.OnNext(i); - } - - var go = new ManualResetEventSlim(); - var tasks = new Task[StressWorkers * StressWorkerGroups]; + public async Task ReentrantOperationsDoNotOverlapValueDelivery() +{ + using var signal = new PrioritySemaphoreSignal(0); + var inside = false; + var overlap = false; + var count = 0; var next = 0; - for (var t = 0; t < StressWorkers; t++) - { - tasks[t] = Task.Run(() => - { - go.Wait(); - for (var i = 0; i < StressIterations; i++) - { - signal.Release(); - signal.OnNext(Interlocked.Increment(ref next)); - } - }); - } - - for (var t = 0; t < StressWorkers; t++) + using var subscription = signal.Subscribe(_ => { - tasks[StressWorkers + t] = Task.Run(() => + overlap |= inside; + inside = true; + count++; + if (count == 1) { - go.Wait(); - for (var i = 0; i < StressIterations; i++) + for (var group = 0; group < OperationGroups; group++) { - signal.MaximumCount = InitialDrainCapacity + (i % CapacityJitter); + for (var iteration = 0; iteration < OperationsPerGroup; iteration++) + { + signal.Release(); + next++; + signal.OnNext(next); + signal.MaximumCount = InitialDrainCapacity + (iteration % CapacityJitter); + next++; + signal.OnNext(InitialDrainCapacity + next); + } } - }); - } + } - for (var t = 0; t < StressWorkers; t++) + inside = false; + }); + for (var value = 0; value < SeededValueCount; value++) { - tasks[(StressWorkers * OnNextTaskOffsetMultiplier) + t] = Task.Run(() => - { - go.Wait(); - for (var i = 0; i < StressIterations; i++) - { - signal.OnNext(InitialDrainCapacity + Interlocked.Increment(ref next)); - } - }); + signal.OnNext(value); } - go.Set(); - await Task.WhenAll(tasks); - + signal.MaximumCount = InitialDrainCapacity; signal.MaximumCount = int.MaxValue; - - const int ExpectedValues = SeededValueCount + (StressWorkers * StressIterations * 2); - for (var i = 0; i < PollIterations && observer.OnNextCount < ExpectedValues; i++) - { - await Task.Delay(PollDelayMilliseconds); - } - - await Assert.That(observer.OverlapDetected).IsFalse(); - await Assert.That(observer.OnNextCount).IsEqualTo(ExpectedValues); - - subscription.Dispose(); + const int ExpectedValues = SeededValueCount + (OperationGroups * OperationsPerGroup * 2); + await Assert.That(overlap).IsFalse(); + await Assert.That(count).IsEqualTo(ExpectedValues); } - /// Terminal completion notifications remain serialized with value delivery under concurrent drain. + /// Reentrant completion waits until value delivery returns. /// A task representing the asynchronous operation. [Test] - public async Task ConcurrentCompletionDoesNotOverlapObserverOnNext() - { + public async Task ReentrantCompletionWaitsForValueDelivery() +{ using var signal = new PrioritySemaphoreSignal(0); - using var releaseOnNext = new ManualResetEventSlim(); - using var observer = new TerminalOverlapProbe(releaseOnNext); - using var subscription = signal.Subscribe(observer); - + List values = []; + var inside = false; + var overlap = false; + var completed = 0; + using var subscription = signal.Subscribe( + value => + { + inside = true; + values.Add(value); + signal.OnCompleted(); + inside = false; + }, + static _ => { }, + () => + { + overlap |= inside; + completed++; + }); signal.OnNext(ThirdValue); signal.OnNext(FirstValue); signal.OnNext(SecondValue); - - // Drive the drain on a dedicated thread so the probe can block it without starving the - // thread pool; the started gate is observed synchronously to keep the race deterministic. - var drainThread = StartThread(() => signal.MaximumCount = 1); - await Assert.That(observer.WaitForOnNextStarted(TerminalProbeTimeout)).IsTrue(); - - var completionThread = StartThread(signal.OnCompleted); - - releaseOnNext.Set(); - drainThread.Join(); - completionThread.Join(); - - await Assert.That(observer.Completed).IsEqualTo(1); - await Assert.That(observer.Values.SequenceEqual([FirstValue, SecondValue, ThirdValue])).IsTrue(); - await Assert.That(observer.OverlapDetected).IsFalse(); + signal.MaximumCount = 1; + await Assert.That(completed).IsEqualTo(1); + await Assert.That(values.SequenceEqual([FirstValue, SecondValue, ThirdValue])).IsTrue(); + await Assert.That(overlap).IsFalse(); } - /// Terminal error notifications remain serialized with value delivery under concurrent drain. + /// Reentrant errors wait until value delivery returns. /// A task representing the asynchronous operation. [Test] - public async Task ConcurrentErrorDoesNotOverlapObserverOnNext() - { + public async Task ReentrantErrorWaitsForValueDelivery() +{ using var signal = new PrioritySemaphoreSignal(0); - using var releaseOnNext = new ManualResetEventSlim(); - using var observer = new TerminalOverlapProbe(releaseOnNext); - using var subscription = signal.Subscribe(observer); - var expected = new InvalidOperationException("expected"); - + List values = []; + var inside = false; + var overlap = false; + InvalidOperationException expected = new("expected"); + Exception? observed = null; + using var subscription = signal.Subscribe( + value => + { + inside = true; + values.Add(value); + signal.OnError(expected); + inside = false; + }, + error => + { + overlap |= inside; + observed = error; + }); signal.OnNext(ThirdValue); signal.OnNext(FirstValue); signal.OnNext(SecondValue); - - // Drive the drain on a dedicated thread so the probe can block it without starving the - // thread pool; the started gate is observed synchronously to keep the race deterministic. - var drainThread = StartThread(() => signal.MaximumCount = 1); - await Assert.That(observer.WaitForOnNextStarted(TerminalProbeTimeout)).IsTrue(); - - var errorThread = StartThread(() => signal.OnError(expected)); - - releaseOnNext.Set(); - drainThread.Join(); - errorThread.Join(); - - await Assert.That(observer.Errors[0]).IsSameReferenceAs(expected); - await Assert.That(observer.Values.Length).IsEqualTo(1); - await Assert.That(observer.OverlapDetected).IsFalse(); + signal.MaximumCount = 1; + await Assert.That(observed).IsSameReferenceAs(expected); + await Assert.That(values.SequenceEqual([FirstValue])).IsTrue(); + await Assert.That(overlap).IsFalse(); } /// A terminal notification arriving after the signal is already terminal is ignored. @@ -381,16 +346,6 @@ public async Task DrainReleasesOwnershipWhenDeliveryThrows() await Assert.That(deliveries).IsEqualTo(FirstDrainCount); } - /// Starts a background thread running the supplied action. - /// The work to run. - /// The started thread. - private static Thread StartThread(Action action) - { - var thread = new Thread(() => action()) { IsBackground = true }; - thread.Start(); - return thread; - } - /// Test sequencer that queues scheduled work until drained explicitly. private sealed class QueuedSequencer : ISequencer { @@ -512,170 +467,4 @@ public void OnNext(T value) } } } - - /// Observer used to detect concurrent and overlap. - private sealed class TerminalOverlapProbe : IObserver, IDisposable - { - /// Gate to hold the first value until assertions can observe interleaving. - private readonly ManualResetEventSlim _releaseOnNext; - - /// Guards internal state. - private readonly Lock _gate = new(); - - /// Captured errors. - private readonly List _errors = []; - - /// Captured values. - private readonly List _values = []; - - /// Signals that one value started delivering. - private readonly ManualResetEventSlim _onNextStarted = new(); - - /// Whether the observer is currently inside . - private int _insideOnNext; - - /// Whether a terminal notification was observed while a value was being delivered. - private int _overlapDetected; - - /// Whether a value was emitted. - private int _onNextCount; - - /// Completed notification count. - private int _completed; - - /// Initializes a new instance of the class. - /// Gate used by tests to hold in-flight. - public TerminalOverlapProbe(ManualResetEventSlim releaseOnNext) => _releaseOnNext = releaseOnNext; - - /// Gets whether overlapping terminal and value notifications were observed. - public bool OverlapDetected => Volatile.Read(ref _overlapDetected) != 0; - - /// Gets the number of delivered values. - public int[] Values - { - get - { - lock (_gate) - { - return [.. _values]; - } - } - } - - /// Gets the number of completed notifications. - public int Completed => Volatile.Read(ref _completed); - - /// Gets the errors delivered to this observer. - public Exception[] Errors - { - get - { - lock (_gate) - { - return [.. _errors]; - } - } - } - - /// Blocks until has started delivering at least one value. - /// The maximum time to wait. - /// when delivery started within the timeout. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool WaitForOnNextStarted(TimeSpan timeout) => _onNextStarted.Wait(timeout); - - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Dispose() => _onNextStarted.Dispose(); - - /// - public void OnNext(int value) - { - if (Interlocked.Exchange(ref _insideOnNext, 1) != 0) - { - _ = Interlocked.Exchange(ref _overlapDetected, 1); - } - - lock (_gate) - { - _onNextCount++; - _values.Add(value); - } - - _onNextStarted.Set(); - _releaseOnNext.Wait(); - _ = Interlocked.Exchange(ref _insideOnNext, 0); - } - - /// - public void OnCompleted() - { - if (Volatile.Read(ref _insideOnNext) != 0) - { - _ = Interlocked.Exchange(ref _overlapDetected, 1); - } - - _ = Interlocked.Increment(ref _completed); - } - - /// - public void OnError(Exception error) - { - if (Volatile.Read(ref _insideOnNext) != 0) - { - _ = Interlocked.Exchange(ref _overlapDetected, 1); - } - - lock (_gate) - { - _errors.Add(error); - } - } - } - - /// Observer used to detect overlapping notifications. - private sealed class ConcurrencyProbe : IObserver - { - /// The spin delay used to amplify overlap detection. - private const int ProbeSpinWaitIterations = 1000; - - /// Non-zero while a notification is in-flight. - private int _inside; - - /// Whether two notifications overlapped. - private int _overlapDetected; - - /// Count of delivered notifications. - private int _onNextCount; - - /// Gets whether overlapping notifications were observed. - public bool OverlapDetected => Volatile.Read(ref _overlapDetected) != 0; - - /// Gets the number of delivered notifications. - public int OnNextCount => Volatile.Read(ref _onNextCount); - - /// Records a single notification value, pausing briefly to amplify overlap races. - /// The observed value. - public void OnNext(int value) - { - if (Interlocked.Exchange(ref _inside, 1) != 0) - { - _ = Interlocked.Exchange(ref _overlapDetected, 1); - } - - _ = Interlocked.Increment(ref _onNextCount); - Thread.SpinWait(ProbeSpinWaitIterations); - _ = Interlocked.Exchange(ref _inside, 0); - } - - /// Implements the generic observer interface for value-only checks. - public void OnCompleted() - { - } - - /// Implements the generic observer interface for value-only checks. - /// The observed error. - public void OnError(Exception error) - { - } - } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj b/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj index f6dcc9c5..272083b3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Tests/ReactiveUI.Primitives.Tests.csproj @@ -18,10 +18,6 @@ - - - - diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs index 9efa4da1..55688d60 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs @@ -121,7 +121,7 @@ public async Task HasObservers() await HasObserversImpl(new()); await HasObserversImpl(new(1)); await HasObserversImpl(new(Three)); - await HasObserversImpl(new(TimeSpan.FromSeconds(1))); + await HasObserversImpl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } /// Determines whether [has observers dispose1]. @@ -132,7 +132,7 @@ public async Task HasObservers_Dispose1() await HasObservers_Dispose1Impl(new()); await HasObservers_Dispose1Impl(new(1)); await HasObservers_Dispose1Impl(new(Three)); - await HasObservers_Dispose1Impl(new(TimeSpan.FromSeconds(1))); + await HasObservers_Dispose1Impl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } /// Determines whether [has observers dispose2]. @@ -143,7 +143,7 @@ public async Task HasObservers_Dispose2() await HasObservers_Dispose2Impl(new()); await HasObservers_Dispose2Impl(new(1)); await HasObservers_Dispose2Impl(new(Three)); - await HasObservers_Dispose2Impl(new(TimeSpan.FromSeconds(1))); + await HasObservers_Dispose2Impl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } /// Determines whether [has observers dispose3]. @@ -154,7 +154,7 @@ public async Task HasObservers_Dispose3() await HasObservers_Dispose3Impl(new()); await HasObservers_Dispose3Impl(new(1)); await HasObservers_Dispose3Impl(new(Three)); - await HasObservers_Dispose3Impl(new(TimeSpan.FromSeconds(1))); + await HasObservers_Dispose3Impl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } /// Determines whether [has observers on completed]. @@ -165,7 +165,7 @@ public async Task HasObservers_OnCompleted() await HasObservers_OnCompletedImpl(new()); await HasObservers_OnCompletedImpl(new(1)); await HasObservers_OnCompletedImpl(new(Three)); - await HasObservers_OnCompletedImpl(new(TimeSpan.FromSeconds(1))); + await HasObservers_OnCompletedImpl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } /// Determines whether [has observers on error]. @@ -176,7 +176,7 @@ public async Task HasObservers_OnError() await HasObservers_OnErrorImpl(new()); await HasObservers_OnErrorImpl(new(1)); await HasObservers_OnErrorImpl(new(Three)); - await HasObservers_OnErrorImpl(new(TimeSpan.FromSeconds(1))); + await HasObservers_OnErrorImpl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } /// Called when [error argument checking]. @@ -215,16 +215,13 @@ public async Task SubjectsReplayBehaviorStateAndConnectableAliasesCoverLateTermi await Assert.That(state.ParamName).IsEqualTo("selector"); } - /// - /// A new subscriber that races a live must receive each value exactly - /// once: the replayed buffer must not duplicate or reorder a value that is also delivered live. - /// + /// Replay and live delivery hold one shared gate and deliver each value once in order. /// A task representing the asynchronous operation. [Test] - public async Task Subscribe_RacingOnNext_DeliversEachValueOnce() + public async Task SubscribeAndOnNextHoldTheSharedDeliveryGate() { - await RaceSubscribeAgainstProducer(static () => new(1)); - await RaceSubscribeAgainstProducer(static () => new(Three)); + await AssertReplayAndLiveDeliveryHoldTheGate(static () => new(1)); + await AssertReplayAndLiveDeliveryHoldTheGate(static () => new(Three)); } /// Asserts a behavior signal keeps its first terminal notification and replays it to late subscribers. @@ -306,41 +303,28 @@ private static async Task AssertWindowedReplayDropsValuesOlderThanTheWindow() await Assert.That(windowedLate.Values.SequenceEqual(expectedWindowedLate)).IsTrue(); } - /// - /// Continuously emits increasing values from one thread while another thread repeatedly subscribes and - /// disposes, asserting that no subscriber ever receives a value out of order or twice. - /// + /// Checks gate ownership and ordered handover from replay to live delivery. /// Factory used to create the replay signal under test. /// A task representing the asynchronous operation. - private static async Task RaceSubscribeAgainstProducer(Func> factory) + private static async Task AssertReplayAndLiveDeliveryHoldTheGate(Func> factory) { - const int subscribeAttempts = 50_000; - using var signal = factory(); - using CancellationTokenSource stop = new(); - var firstFailure = default(OrderingWitness.OutOfOrderDelivery); - - var producer = Task.Run(() => + List values = []; + var ownsGate = true; + signal.OnNext(1); + using var subscription = signal.Subscribe(value => { - var value = 0; - while (!stop.IsCancellationRequested) - { - value++; - signal.OnNext(value); - } + values.Add(value); +#if NET9_0_OR_GREATER + ownsGate &= signal.Gate.IsHeldByCurrentThread; +#else + ownsGate &= Monitor.IsEntered(signal.Gate); +#endif }); - - for (var attempt = 0; attempt < subscribeAttempts && firstFailure is null; attempt++) - { - OrderingWitness witness = new(); - signal.Subscribe(witness).Dispose(); - firstFailure = witness.OutOfOrder; - } - - await stop.CancelAsync(); - await producer; - - await Assert.That(firstFailure).IsNull(); + signal.OnNext(Two); + signal.OnNext(Three); + await Assert.That(ownsGate).IsTrue(); + await Assert.That(values.SequenceEqual([1, Two, Three])).IsTrue(); } /// Creates a replay signal and disposes it immediately. diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.DynamicDataCompatibility.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.DynamicDataCompatibility.cs index 681a5409..b60729f5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.DynamicDataCompatibility.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.DynamicDataCompatibility.cs @@ -311,7 +311,6 @@ public async Task DynamicDataRxConvenienceAliasesCoverPublicEntryPoints() _ = Signal.FromEnumerable([Task.FromResult(One), Task.FromResult(Two)]).Concat().Subscribe(aliasValues.Add); _ = ((IEnumerable>)[Signal.Return(One), Signal.Return(Two)]).Merge() .Subscribe(aliasValues.Add); - await Task.Yield(); await Assert.That(aliasValues.SequenceEqual([One, Two, Three, One, Two, Three, One, Three, One, Two, One, Two])) .IsTrue(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs index dfb010d0..d4c5c63a 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs @@ -514,10 +514,7 @@ private static List Collect(IObservable source) private static IObservable> RangeInners() => Signal.FromEnumerable([Signal.Sequence(One, Two), Signal.Sequence(Three, Two)]); - /// - /// Drives a stateful sink through a value, a terminal completion, and then further notifications, reporting - /// whether the post-terminal notifications were dropped (exactly one completion, no leaked error). - /// + /// Checks that a stateful sink drops every notification after its first completion. /// The stateful operator under test. /// when notifications after the terminal were dropped. private static bool RunStopGuards(Func, IObservable> op) @@ -623,10 +620,7 @@ public CombineLatestSources() => public Signal Sixteenth { get; } = new(); } - /// - /// An observable whose subscription retains its observer and ignores disposal, letting a test push raw - /// notifications (including ones after a terminal notification) to exercise a sink's terminal guards. - /// + /// Retains its observer after disposal so tests can deliver post-terminal notifications. /// The element type. private sealed class ManualSource : IObservable { diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs new file mode 100644 index 00000000..4139c9af --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs @@ -0,0 +1,82 @@ +// 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.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Verifies scheduler selection by time-operator aliases. +public partial class RxNamesTests +{ + /// Verifies absolute-time overloads use the default scheduler when no scheduler is supplied. + /// A task representing the asynchronous operation. + [Test] + public async Task AbsoluteTimeOperatorsUseDefaultScheduler() + { + var dueTime = DateTimeOffset.UnixEpoch; + AwaitableWitness delayedScalar = new(); + AwaitableWitness delayedRange = new(); + AwaitableWitness delayedSubscriptionScalar = new(); + AwaitableWitness delayedSubscriptionRange = new(); + AwaitableWitness delayedExplicitRange = new(); + AwaitableWitness delayedSubscriptionExplicitRange = new(); + AwaitableWitness timeout = new(); + AwaitableWitness explicitTimeout = new(); + const ISequencer? defaultScheduler = null; + + using var delayScalarSubscription = Signal.Emit(One) + .Delay(dueTime) + .Subscribe(delayedScalar); + using var delayRangeSubscription = Signal.Sequence(Two, Two) + .Delay(dueTime) + .Subscribe(delayedRange); + using var delayExplicitRangeSubscription = Signal.Sequence(Two, Two) + .Delay(dueTime, defaultScheduler) + .Subscribe(delayedExplicitRange); + using var subscriptionScalarSubscription = Signal.Emit(One) + .DelaySubscription(dueTime) + .Subscribe(delayedSubscriptionScalar); + using var subscriptionRangeSubscription = Signal.Sequence(Two, Two) + .DelaySubscription(dueTime) + .Subscribe(delayedSubscriptionRange); + using var subscriptionExplicitRangeSubscription = Signal.Sequence(Two, Two) + .DelaySubscription(dueTime, defaultScheduler) + .Subscribe(delayedSubscriptionExplicitRange); + using var timeoutSubscription = Signal.Silent() + .Timeout(dueTime) + .Subscribe(timeout); + using var explicitTimeoutSubscription = Signal.Silent() + .Timeout(dueTime, defaultScheduler) + .Subscribe(explicitTimeout); + + await delayedScalar.ValueCountReaching(One); + await delayedRange.ValueCountReaching(Two); + await delayedExplicitRange.ValueCountReaching(Two); + await delayedSubscriptionScalar.ValueCountReaching(One); + await delayedSubscriptionRange.ValueCountReaching(Two); + await delayedSubscriptionExplicitRange.ValueCountReaching(Two); + var timedOut = await timeout.FirstError; + var explicitlyTimedOut = await explicitTimeout.FirstError; + + await Assert.That(delayedScalar.Values.SequenceEqual([One])).IsTrue(); + await Assert.That(delayedRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(delayedExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(delayedSubscriptionScalar.Values.SequenceEqual([One])).IsTrue(); + await Assert.That(delayedSubscriptionRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(delayedSubscriptionExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); + await Assert.That(timedOut).IsTypeOf(); + await Assert.That(explicitlyTimedOut).IsTypeOf(); + } + + /// Time aliases construct signals when no sequencer is supplied. + /// A task representing the asynchronous operation. + [Test] + public async Task TimeOperatorsAcceptDefaultSequencer() + { + await Assert.That(Signal.Sequence(One, Three).Delay(TimeSpan.FromTicks(DueTicks))).IsNotNull(); + await Assert.That(Signal.FromEnumerable(_oneToThree).Timeout(TimeSpan.FromTicks(DueTicks))).IsNotNull(); + await Assert.That(Signal.FromEnumerable(_oneToThree).Sample(TimeSpan.FromTicks(DueTicks))).IsNotNull(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs index 6c479ba2..ec4fa688 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs @@ -11,11 +11,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Data-driven parity tests proving each System.Reactive/LINQ name builds a behaviorally identical sink to its -/// Primitives-named counterpart. Each operator pair is one data-source row consumed by a single test body, so the -/// behavior is asserted once and checked for both names (and for identity between them). -/// +/// Verifies Rx and LINQ aliases preserve their corresponding signal operator behavior. public partial class RxNamesTests { /// The multiplier/state used by projection cases. @@ -60,9 +56,6 @@ public partial class RxNamesTests /// The amount the virtual clock is advanced, comfortably past . private const long AdvanceTicks = 5; - /// How far in the past the absolute due time of the default-scheduler operators is placed. - private const int ElapsedDueSeconds = 2; - /// Source values 1..5. private static readonly int[] _oneToFive = [1, 2, 3, 4, 5]; @@ -434,66 +427,6 @@ await Assert .IsRequiredSubscribeOnCurrentThread()).IsFalse(); } - /// Verifies absolute-time overloads use the default scheduler when no scheduler is supplied. - /// A task representing the asynchronous operation. - [Test] - public async Task AbsoluteTimeOperatorsUseDefaultScheduler() - { - var dueTime = ThreadPoolSequencer.Instance.Now.AddSeconds(-ElapsedDueSeconds); - AwaitableWitness delayedScalar = new(); - AwaitableWitness delayedRange = new(); - AwaitableWitness delayedSubscriptionScalar = new(); - AwaitableWitness delayedSubscriptionRange = new(); - AwaitableWitness delayedExplicitRange = new(); - AwaitableWitness delayedSubscriptionExplicitRange = new(); - AwaitableWitness timeout = new(); - AwaitableWitness explicitTimeout = new(); - const ISequencer? defaultScheduler = null; - - using var delayScalarSubscription = Signal.Emit(One) - .Delay(dueTime) - .Subscribe(delayedScalar); - using var delayRangeSubscription = Signal.Sequence(Two, Two) - .Delay(dueTime) - .Subscribe(delayedRange); - using var delayExplicitRangeSubscription = Signal.Sequence(Two, Two) - .Delay(dueTime, defaultScheduler) - .Subscribe(delayedExplicitRange); - using var subscriptionScalarSubscription = Signal.Emit(One) - .DelaySubscription(dueTime) - .Subscribe(delayedSubscriptionScalar); - using var subscriptionRangeSubscription = Signal.Sequence(Two, Two) - .DelaySubscription(dueTime) - .Subscribe(delayedSubscriptionRange); - using var subscriptionExplicitRangeSubscription = Signal.Sequence(Two, Two) - .DelaySubscription(dueTime, defaultScheduler) - .Subscribe(delayedSubscriptionExplicitRange); - using var timeoutSubscription = Signal.Silent() - .Timeout(dueTime) - .Subscribe(timeout); - using var explicitTimeoutSubscription = Signal.Silent() - .Timeout(dueTime, defaultScheduler) - .Subscribe(explicitTimeout); - - await delayedScalar.ValueCountReaching(One); - await delayedRange.ValueCountReaching(Two); - await delayedExplicitRange.ValueCountReaching(Two); - await delayedSubscriptionScalar.ValueCountReaching(One); - await delayedSubscriptionRange.ValueCountReaching(Two); - await delayedSubscriptionExplicitRange.ValueCountReaching(Two); - var timedOut = await timeout.FirstError; - var explicitlyTimedOut = await explicitTimeout.FirstError; - - await Assert.That(delayedScalar.Values.SequenceEqual([One])).IsTrue(); - await Assert.That(delayedRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedSubscriptionScalar.Values.SequenceEqual([One])).IsTrue(); - await Assert.That(delayedSubscriptionRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedSubscriptionExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(timedOut).IsTypeOf(); - await Assert.That(explicitlyTimedOut).IsTypeOf(); - } - /// Verifies the binary Concat/Chain overload concatenates two sequences identically. /// A task representing the asynchronous operation. [Test] @@ -837,18 +770,6 @@ public async Task RxNamesRangeFastPathsMatchCounterparts() public async Task RetryMirrorsSourceWhenNoError() => await Assert.That(Collect(Signal.FromEnumerable(_oneToThree).Retry(Two)).SequenceEqual(_oneToThree)).IsTrue(); - /// Exercises the default-sequencer (no-scheduler) overloads of the time operators. - /// A task representing the asynchronous operation. - [Test] - public async Task TimeOperatorsAcceptDefaultSequencer() - { - Signal.Sequence(One, Three).Delay(TimeSpan.FromTicks(DueTicks)).Subscribe(static _ => { }).Dispose(); - Signal.FromEnumerable(_oneToThree).Timeout(TimeSpan.FromSeconds(AdvanceTicks)).Subscribe(static _ => { }) - .Dispose(); - Signal.FromEnumerable(_oneToThree).Sample(TimeSpan.FromTicks(DueTicks)).Subscribe(static _ => { }).Dispose(); - await Task.CompletedTask.ConfigureAwait(false); - } - /// Verifies the stateful sinks drop notifications that arrive after a terminal notification. /// A task representing the asynchronous operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs index 61e5557e..652a9bea 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs @@ -7,12 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the three wrappers can hand back: the delegate -/// witness made safe in place, the cancel-owning safe wrapper, and the cancel-free safe wrapper. All three -/// promise the same thing — one terminal signal reaches the wrapped observer, nothing reaches it afterwards, -/// and a throwing value callback stops the witness for good. -/// +/// Tests that safe witnesses stop after one terminal signal or a throwing value callback. public class SafeWitnessTests { /// The first value pushed at a witness. diff --git a/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs index 0833eddd..f869ddd0 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs @@ -24,14 +24,11 @@ public sealed class ScheduledSignalTests /// The fourth emitted value. private const int FourthValue = 4; - /// The final emitted value used by stress tests. + /// The value delivered after the final subscription is disposed. private const int FinalValue = 999; - /// The number of concurrent workers used by stress tests. - private const int WorkerCount = 8; - - /// The number of subscribe/dispose iterations each stress worker runs. - private const int WorkerIterations = 64; + /// The number of overlapping subscriptions. + private const int SubscriptionCount = 8; /// The expected terminal exception message. private const string TerminalThrowMessage = "terminal"; @@ -198,37 +195,30 @@ public async Task SubscribeRestoresDefaultObserverWhenTerminalReplayThrows() await Assert.That(lateObserver.Completed).IsEqualTo(1); } - /// Concurrent subscribe and dispose operations do not prevent default-observer restoration. + /// Disposing the final explicit subscription restores the default observer. /// A task representing the asynchronous operation. [Test] - public async Task ConcurrentSubscribeDisposeRestoresDefaultObserver() + public async Task DisposingOverlappingSubscriptionsRestoresDefaultObserver() { var defaultObserver = new RecordingObserver(); using var signal = new ScheduledSignal(Sequencer.Immediate, defaultObserver); - var tasks = Enumerable.Range(0, WorkerCount) - .Select(worker => Task.Run(() => SubscribeAndDispose(signal, worker))) - .ToArray(); + var subscriptions = new IDisposable[SubscriptionCount]; + for (var index = 0; index < subscriptions.Length; index++) + { + subscriptions[index] = signal.Subscribe(new RecordingObserver()); + } - await Task.WhenAll(tasks); + for (var index = 0; index < subscriptions.Length; index++) + { + signal.OnNext(FirstValue); + subscriptions[index].Dispose(); + } signal.OnNext(FinalValue); - - await Assert.That(defaultObserver.Values.Contains(FinalValue)).IsTrue(); + await Assert.That(defaultObserver.Values.SequenceEqual([FinalValue])).IsTrue(); await Assert.That(signal.IsDisposed).IsFalse(); } - /// Subscribes and disposes observers repeatedly. - /// The scheduled signal under test. - /// The worker identifier. - private static void SubscribeAndDispose(ScheduledSignal signal, int worker) - { - for (var i = 0; i < WorkerIterations; i++) - { - using var subscription = signal.Subscribe(new RecordingObserver()); - signal.OnNext((worker * WorkerIterations) + i); - } - } - /// Test sequencer that queues scheduled work until drained explicitly. private sealed class QueuedSequencer : ISequencer { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs new file mode 100644 index 00000000..ddfbb345 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests cancellation across queued, waiting, and completed work states. +public partial class SequencerTests +{ + /// A canceled trampoline item is skipped before waiting or after its wait returns. + /// A task representing the asynchronous test. + [Test] + public async Task CurrentThreadTrampolineChecksCancellationAroundTheWait() + { + SequencerQueue queue = new(); + var runs = 0; + ScheduledItem canceled = new(0, Comparer.Default, _ => + { + runs++; + return EmptyDisposable.Instance; + }); + ScheduledItem waiting = new(Stopwatch.Frequency, Comparer.Default, _ => + { + runs++; + return EmptyDisposable.Instance; + }); + canceled.Cancel(); + queue.Enqueue(canceled); + queue.Enqueue(waiting); + List waits = []; + CurrentThreadSequencer.Trampoline.Run(queue, static () => 0, delay => + { + waits.Add(delay); + waiting.Cancel(); + }); + await Assert.That(waits.SequenceEqual([TimeSpan.FromSeconds(1)])).IsTrue(); + await Assert.That(runs).IsEqualTo(0); + await Assert.That(queue.Count).IsEqualTo(0); + } + + /// Only positive remaining delays invoke the wait operation. + /// A task representing the asynchronous test. + [Test] + public async Task CurrentThreadWaitsOnlyForPositiveDelays() + { + List waits = []; + CurrentThreadSequencer.WaitIfNeeded(TimeSpan.Zero, waits.Add); + CurrentThreadSequencer.WaitIfNeeded(TimeSpan.FromTicks(-1), waits.Add); + CurrentThreadSequencer.WaitIfNeeded(TimeSpan.FromTicks(1), waits.Add); + await Assert.That(waits.SequenceEqual([TimeSpan.FromTicks(1)])).IsTrue(); + + var runs = 0; + CurrentThreadSequencer.ActionWorkItem item = new(() => runs++); + item.Dispose(); + item.Execute(); + await Assert.That(runs).IsEqualTo(0); + await Assert.That(item.IsDisposed).IsTrue(); + } + + /// Canceled immediate and delayed work never executes when the manual pool drains. + /// A task representing the asynchronous test. + [Test] + public async Task ThreadPoolDropsCanceledWorkFromBothQueues() + { + using ManualThreadPool pool = new(); + CancellableWorkItem immediate = new(); + CancellableWorkItem delayed = new(); + pool.Sequencer.Schedule(immediate); + pool.Sequencer.Schedule(delayed, One); + immediate.Dispose(); + delayed.Dispose(); + pool.RunReady(); + pool.RunDue(One); + await Assert.That(immediate.ExecuteCount).IsEqualTo(0); + await Assert.That(delayed.ExecuteCount).IsEqualTo(0); + } + + /// A task canceled after dispatch is skipped when its task scheduler runs it. + /// A task representing the asynchronous test. + [Test] + public async Task TaskPoolSkipsWorkCanceledAfterDispatch() + { + ManualTaskScheduler scheduler = new(); + TaskPoolSequencer sequencer = new(new(scheduler)); + CancellableWorkItem item = new(); + sequencer.Schedule(item); + item.Dispose(); + scheduler.RunPending(); + await Assert.That(item.ExecuteCount).IsEqualTo(0); + } + + /// Cancellation after result publication releases the owned resource exactly once. + /// A task representing the asynchronous test. + [Test] + public async Task ThreadPoolFinalCancellationCleanupDoesNotReleaseTheResultTwice() + { + using ManualThreadPool pool = new(); + var releases = 0; + ThreadPoolSequencer.ScheduledWorkItem item = new( + pool.Sequencer, + 0, + (_, _) => new ActionDisposable(() => releases++)); + item.Execute(); + item.Dispose(); + item.ReleaseCanceledResult(); + await Assert.That(releases).IsEqualTo(1); + await Assert.That(item.IsDisposed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs index e1676ff6..f3c9b7a0 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs @@ -8,27 +8,12 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the sequencers that hand work to a pool or a dispatcher: how a faulting work item is routed, how the -/// shared delay timer is armed and released, and how delayed work is marshalled back onto its own thread. -/// +/// Tests delayed dispatch, cancellation, and failure routing through pool and context sequencers. public partial class SequencerTests { /// Message carried by the work item that faults on purpose. private const string FaultMessage = "scheduled work failed"; - /// How far ahead delayed work is scheduled, so the sequencer's delay timer really has to arm. - private static readonly TimeSpan DelayedDueTime = TimeSpan.FromMilliseconds(20); - - /// How far ahead work that is cancelled before it becomes due is scheduled. - private static readonly TimeSpan CancelledDueTime = TimeSpan.FromMilliseconds(100); - - /// How long a test watches for work that must never run. - private static readonly TimeSpan CancelObservationWindow = TimeSpan.FromMilliseconds(400); - - /// How long a test lets a drain finish disarming before it releases the sequencer's timer. - private static readonly TimeSpan DrainSettleWindow = TimeSpan.FromMilliseconds(100); - /// Verifies a faulting work item is handed to the sequencer's unhandled-exception handler. /// A task representing the asynchronous operation. [Test] @@ -67,63 +52,68 @@ public async Task TaskPoolSequencerRethrowsWorkItemFailuresWithoutAHandler() /// A task representing the asynchronous operation. [Test] public async Task ThreadPoolSequencerRunsDelayedWorkAndDrainsItsQueue() - { - using var sequencer = CreateIsolatedThreadPoolSequencer(); - TaskCompletionSource ran = new(TaskCreationOptions.RunContinuationsAsynchronously); - - sequencer.Schedule( - new CallbackWorkItem(() => ran.TrySetResult()), - Sequencer.AddTimestamp(sequencer.Timestamp, DelayedDueTime)); - - await ran.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); - await Assert.That(ran.Task.IsCompletedSuccessfully).IsTrue(); +{ + using ManualThreadPool pool = new(); + CancellableWorkItem first = new(); + CancellableWorkItem second = new(); + pool.Sequencer.Schedule(first, One); + pool.Sequencer.Schedule(second, Two); + pool.RunDue(0); + await Assert.That(first.ExecuteCount).IsEqualTo(0); + pool.RunDue(One); + await Assert.That(first.ExecuteCount).IsEqualTo(1); + await Assert.That(second.ExecuteCount).IsEqualTo(0); + pool.RunDue(Two); + await Assert.That(second.ExecuteCount).IsEqualTo(1); + await Assert.That(pool.Delays[^1]).IsEqualTo(Timeout.InfiniteTimeSpan); + } - // Let the drain finish disarming the timer before this scope releases it. - await Task.Delay(DrainSettleWindow); + /// Elapsed and current timestamps queue immediate work without arming a timer. + /// The asynchronous test. + [Test] + public async Task ThreadPoolSequencerRoutesElapsedTimestampsToItsImmediateQueue() + { + using ManualThreadPool pool = new() { Timestamp = Two }; + CancellableWorkItem elapsed = new(); + CancellableWorkItem current = new(); + pool.Sequencer.Schedule(elapsed, One); + pool.Sequencer.Schedule(current, Two); + await Assert.That(elapsed.ExecuteCount).IsEqualTo(0); + await Assert.That(current.ExecuteCount).IsEqualTo(0); + await Assert.That(pool.Delays).IsEmpty(); + pool.RunReady(); + await Assert.That(elapsed.ExecuteCount).IsEqualTo(1); + await Assert.That(current.ExecuteCount).IsEqualTo(1); } - /// - /// Verifies a disposed thread-pool sequencer rejects new work rather than accepting work it can never run: the - /// delay timer is gone, so an accepted delayed item would sit in the queue forever, and an accepted immediate - /// item would run on a sequencer its owner has already torn down. Both overloads fail fast instead. - /// + /// A disposed thread-pool sequencer rejects immediate and delayed work. /// A task representing the asynchronous operation. [Test] public async Task ThreadPoolSequencerRejectsWorkScheduledAfterDispose() - { - var sequencer = CreateIsolatedThreadPoolSequencer(); +{ + using ManualThreadPool pool = new(); CancellableWorkItem immediate = new(); CancellableWorkItem delayed = new(); - - sequencer.Dispose(); - - await Assert.That(() => sequencer.Schedule(immediate)).ThrowsExactly(); - await Assert - .That(() => sequencer.Schedule(delayed, Sequencer.AddTimestamp(sequencer.Timestamp, DelayedDueTime))) - .ThrowsExactly(); - - await Task.Delay(CancelObservationWindow); + pool.Sequencer.Dispose(); + await Assert.That(() => pool.Sequencer.Schedule(immediate)).ThrowsExactly(); + await Assert.That(() => pool.Sequencer.Schedule(delayed, One)).ThrowsExactly(); + pool.RunReady(); + pool.RunDue(One); await Assert.That(immediate.ExecuteCount).IsEqualTo(0); await Assert.That(delayed.ExecuteCount).IsEqualTo(0); } - /// - /// Verifies disposing a thread-pool sequencer releases the delayed work still queued behind its timer: the - /// pending item is cancelled, not stranded in the queue of a sequencer that can no longer arm a timer for it. - /// + /// Disposal cancels queued delayed work. /// A task representing the asynchronous operation. [Test] public async Task ThreadPoolSequencerDisposeCancelsQueuedDelayedWork() - { - var sequencer = CreateIsolatedThreadPoolSequencer(); +{ + using ManualThreadPool pool = new(); CancellableWorkItem pending = new(); - - sequencer.Schedule(pending, Sequencer.AddTimestamp(sequencer.Timestamp, CancelledDueTime)); - sequencer.Dispose(); - + pool.Sequencer.Schedule(pending, One); + pool.Sequencer.Dispose(); + pool.RunDue(One); await Assert.That(pending.IsDisposed).IsTrue(); - - await Task.Delay(CancelObservationWindow); await Assert.That(pending.ExecuteCount).IsEqualTo(0); } @@ -143,20 +133,43 @@ public async Task SynchronizationContextSequencerSkipsCancelledPostedWork() await Assert.That(item.ExecuteCount).IsEqualTo(0); } - /// Verifies delayed work is marshalled back through the synchronization context once it is due. - /// A task representing the asynchronous operation. + /// The current-context factory captures the active context and rejects an absent context. + /// A task representing the asynchronous test. [Test] - public async Task SynchronizationContextSequencerPostsDelayedWorkOnceItIsDue() + public async Task SynchronizationContextSequencerCapturesTheCurrentContext() { + var previous = SynchronizationContext.Current; RecordingSynchronizationContext context = new(); - SynchronizationContextSequencer sequencer = new(context); - TaskCompletionSource ran = new(TaskCreationOptions.RunContinuationsAsynchronously); + CancellableWorkItem item = new(); + try + { + SynchronizationContext.SetSynchronizationContext(context); + SynchronizationContextSequencer.Current.Schedule(item); + SynchronizationContext.SetSynchronizationContext(null); + _ = Assert.Throws(static () => _ = SynchronizationContextSequencer.Current); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } - sequencer.Schedule( - new CallbackWorkItem(() => ran.TrySetResult()), - Sequencer.AddTimestamp(sequencer.Timestamp, DelayedDueTime)); + await Assert.That(context.PostCount).IsEqualTo(1); + await Assert.That(item.ExecuteCount).IsEqualTo(1); + } - await ran.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + /// Verifies delayed work is marshalled back through the synchronization context once it is due. + /// A task representing the asynchronous operation. + [Test] + public async Task SynchronizationContextSequencerPostsDelayedWorkOnceItIsDue() +{ + RecordingSynchronizationContext context = new(); + ManualSequencer delays = new(); + SynchronizationContextSequencer sequencer = new(context, delays); + CancellableWorkItem item = new(); + sequencer.Schedule(item, long.MaxValue); + await Assert.That(context.PostCount).IsEqualTo(0); + delays.RunPending(); + await Assert.That(item.ExecuteCount).IsEqualTo(1); await Assert.That(context.PostCount).IsEqualTo(1); } @@ -164,90 +177,51 @@ public async Task SynchronizationContextSequencerPostsDelayedWorkOnceItIsDue() /// A task representing the asynchronous operation. [Test] public async Task SynchronizationContextSequencerDropsDelayedWorkCancelledBeforeItIsDue() - { +{ RecordingSynchronizationContext context = new(); - SynchronizationContextSequencer sequencer = new(context); + ManualSequencer delays = new(); + SynchronizationContextSequencer sequencer = new(context, delays); CancellableWorkItem item = new(); - - sequencer.Schedule(item, Sequencer.AddTimestamp(sequencer.Timestamp, CancelledDueTime)); + sequencer.Schedule(item, long.MaxValue); item.Dispose(); - - await Task.Delay(CancelObservationWindow); - + delays.RunPending(); await Assert.That(item.ExecuteCount).IsEqualTo(0); await Assert.That(context.PostCount).IsEqualTo(0); } - /// - /// Verifies disposing a thread-pool sequencer twice releases its queued work exactly once and leaves the sequencer - /// closed. The second disposal must be a no-op rather than a second release of work the first disposal already - /// cancelled and handed back to its owner. - /// + /// Repeated disposal releases queued work once and keeps the sequencer closed. /// A task representing the asynchronous operation. [Test] public async Task ThreadPoolSequencerDisposeIsIdempotent() - { - var sequencer = CreateIsolatedThreadPoolSequencer(); +{ + using ManualThreadPool pool = new(); DisposeCountingWorkItem pending = new(); - - sequencer.Schedule(pending, Sequencer.AddTimestamp(sequencer.Timestamp, CancelledDueTime)); - - sequencer.Dispose(); + pool.Sequencer.Schedule(pending, One); + pool.Sequencer.Dispose(); await Assert.That(pending.DisposeCount).IsEqualTo(1); - - await Assert.That(sequencer.Dispose).ThrowsNothing(); - - // The queued item was released once, and the sequencer is still closed rather than reopened by the second call. + await Assert.That(pool.Sequencer.Dispose).ThrowsNothing(); await Assert.That(pending.DisposeCount).IsEqualTo(1); - await Assert.That(() => sequencer.Schedule(new CancellableWorkItem())) + await Assert.That(() => pool.Sequencer.Schedule(new CancellableWorkItem())) .ThrowsExactly(); } - /// - /// Verifies a drain still unwinding when the sequencer is disposed does not re-arm the timer disposal has already - /// released. The drain runs its items outside the gate, so disposal can land mid-drain; when the drain takes the - /// gate again it must observe the disposal and stop rather than arm a timer that no longer exists. - /// + /// Disposal during a drain cancels pending work and prevents timer rearming. /// A task representing the asynchronous operation. [Test] public async Task ThreadPoolSequencerDisposeDuringADrainStopsTheDrainRearmingTheTimer() - { - var sequencer = CreateIsolatedThreadPoolSequencer(); - TaskCompletionSource draining = new(TaskCreationOptions.RunContinuationsAsynchronously); - using ManualResetEventSlim release = new(false); - - // Park the drain inside a due item, so the disposal below provably lands while RunDue is mid-loop. - sequencer.Schedule( - new CallbackWorkItem(() => - { - _ = draining.TrySetResult(); - _ = release.Wait(TimeSpan.FromSeconds(TimeoutSeconds)); - }), - Sequencer.AddTimestamp(sequencer.Timestamp, DelayedDueTime)); - - await draining.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); - +{ + using ManualThreadPool pool = new(); CancellableWorkItem queued = new(); - sequencer.Schedule(queued, Sequencer.AddTimestamp(sequencer.Timestamp, CancelledDueTime)); - - sequencer.Dispose(); - - // Let the parked drain resume: it must unwind quietly instead of arming the timer disposal released. - release.Set(); - await Task.Delay(CancelObservationWindow); - + pool.Sequencer.Schedule(new CallbackWorkItem(pool.Sequencer.Dispose), One); + pool.Sequencer.Schedule(queued, Two); + var changes = pool.Delays.Count; + pool.RunDue(One); await Assert.That(queued.IsDisposed).IsTrue(); await Assert.That(queued.ExecuteCount).IsEqualTo(0); - await Assert.That(sequencer.Dispose).ThrowsNothing(); + await Assert.That(pool.Delays.Count).IsEqualTo(changes); + await Assert.That(pool.Sequencer.Dispose).ThrowsNothing(); } - /// - /// Creates a thread-pool sequencer that owns its own delay queue and timer, so a test can dispose it without - /// disturbing the shared singleton every other test schedules through. - /// - /// The isolated sequencer. - private static ThreadPoolSequencer CreateIsolatedThreadPoolSequencer() => new(); - /// Work item that counts how many times a sequencer released it. private sealed class DisposeCountingWorkItem : IWorkItem, IsDisposed { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs index 2766d016..b73c09bd 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs @@ -147,10 +147,7 @@ public async Task VirtualTimeSequencerExposesItsStopwatchProvider() await Assert.That(stopwatch.Elapsed).IsEqualTo(TimeSpan.FromTicks(VirtualDelay)); } - /// - /// Creates a virtual-time sequencer whose clock is a raw tick count. Unlike it does not - /// normalize a negative relative time away, so the sequencer's own arithmetic delegates and guards are exercised. - /// + /// Creates a virtual sequencer that preserves signed relative tick counts. /// The sequencer. private static VirtualTimeSequencer CreateTickSequencer() => new( diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs index 52fc9694..b55075fa 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs @@ -16,9 +16,6 @@ public partial class SequencerTests /// A monotonic timestamp delta used to drive the delay conversions. private const long DueTimestamp = 1000; - /// How many times a test replays the cancel-versus-start race before checking nothing leaked. - private const int StartCancelRaceAttempts = 2000; - /// Verifies a monotonic delta that has already elapsed converts to no delay at all. /// A task representing the asynchronous operation. [Test] @@ -138,22 +135,19 @@ public async Task ScheduledWorkItemDoesNotRunAfterCancellation() /// Verifies queueing a thread-pool work item hands it to the pool for execution. /// A task representing the asynchronous operation. [Test] - public async Task ScheduledWorkItemQueueRunsOnTheThreadPool() - { - TaskCompletionSource ran = new(TaskCreationOptions.RunContinuationsAsynchronously); - ThreadPoolSequencer.ScheduledWorkItem item = new( - ThreadPoolSequencer.Instance, - One, - (_, _) => - { - _ = ran.TrySetResult(); - return EmptyDisposable.Instance; - }); - + public async Task ScheduledWorkItemQueuesItsExecutionCallback() +{ + using ManualThreadPool pool = new(); + var ran = false; + ThreadPoolSequencer.ScheduledWorkItem item = new(pool.Sequencer, One, (_, _) => + { + ran = true; + return EmptyDisposable.Instance; + }); item.Queue(); - - await ran.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); - await Assert.That(ran.Task.IsCompletedSuccessfully).IsTrue(); + await Assert.That(ran).IsFalse(); + pool.RunReady(); + await Assert.That(ran).IsTrue(); } /// Verifies the stateful scheduling overloads that take a due time run their callbacks. @@ -173,11 +167,7 @@ public async Task ScheduleActionDueTimeOverloadsRunTheirCallbacks() await Assert.That(values.SequenceEqual(ExpectedOneTwo)).IsTrue(); } - /// - /// Verifies the delayed work item the sequencer's heap stores is a value with identity semantics: two entries are - /// equal only when they carry the very same work item and the same due timestamp. The heap dedupes and reorders - /// entries, so two distinct items that merely look alike must never compare equal, and the hash must agree. - /// + /// Timed work item equality and hashing use work item identity and due timestamp. /// A task representing the asynchronous operation. [Test] public async Task TimedWorkItemComparesByWorkItemIdentityAndDueTimestamp() @@ -203,48 +193,22 @@ public async Task TimedWorkItemComparesByWorkItemIdentityAndDueTimestamp() await Assert.That(item.Equals(new object())).IsFalse(); } - /// - /// Verifies a cancellation that lands while the action is starting still releases whatever the action returned. - /// The work item claims cancellation and the action's result in two separate steps, so a dispose that slips - /// between them would otherwise leave the returned disposable owned by nobody and never torn down. - /// + /// Cancellation during invocation disposes the action's returned resource exactly once. /// A task representing the asynchronous operation. [Test] - public async Task ScheduledWorkItemReleasesItsActionResultWhenCancellationRacesTheStart() + public async Task ScheduledWorkItemReleasesItsActionResultWhenCanceledDuringInvocation() { - var created = 0; var disposed = 0; - - for (var attempt = 0; attempt < StartCancelRaceAttempts; attempt++) + ThreadPoolSequencer.ScheduledWorkItem? item = null; + item = new(ThreadPoolSequencer.Instance, One, (_, _) => { - using ManualResetEventSlim actionReturning = new(false); - ThreadPoolSequencer.ScheduledWorkItem item = new( - ThreadPoolSequencer.Instance, - One, - (_, _) => - { - // Let the canceller run at the moment the action hands its result back. - actionReturning.Set(); - _ = Interlocked.Increment(ref created); - return new ActionDisposable(() => Interlocked.Increment(ref disposed)); - }); - - var canceller = Task.Run(() => - { - _ = actionReturning.Wait(TimeSpan.FromSeconds(TimeoutSeconds)); - item.Dispose(); - }); - - item.Execute(); - await canceller; - - // Whichever side won, the item is cancelled, so the action's result must not survive it. - item.Dispose(); - } - - // Every disposable the action handed back was released: none was stranded by the cancel-versus-start race. - await Assert.That(Volatile.Read(ref disposed)).IsEqualTo(Volatile.Read(ref created)); - await Assert.That(Volatile.Read(ref created)).IsEqualTo(StartCancelRaceAttempts); + item!.Dispose(); + return new ActionDisposable(() => disposed++); + }); + item.Execute(); + item.Dispose(); + await Assert.That(item.IsDisposed).IsTrue(); + await Assert.That(disposed).IsEqualTo(1); } /// Work item that counts executions and can be cancelled before a sequencer reaches it. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs index 706815bf..fafe8ba7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs @@ -2,7 +2,6 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics; using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Core; @@ -34,9 +33,6 @@ public partial class SequencerTests /// A reusable negative value. private const int NegativeOne = -1; - /// Timeout used when waiting for background scheduled work. - private const int TimeoutSeconds = 10; - /// Reused first-error message. private const string FirstMessage = "first"; @@ -55,12 +51,6 @@ public partial class SequencerTests /// Deterministic absolute due time for scheduler overload tests. private static readonly DateTimeOffset AbsoluteDueTime = DateTimeOffset.UnixEpoch; - /// How far ahead of now the immediate sequencer's absolute-due-time test schedules its work. - private static readonly TimeSpan AbsoluteDueOffset = TimeSpan.FromMilliseconds(30); - - /// The shortest wait that still proves the immediate sequencer honoured the absolute due time. - private static readonly TimeSpan MinimumAbsoluteWait = TimeSpan.FromMilliseconds(20); - /// Expected values produced by simple scheduling extension overloads. private static readonly int[] ScheduleExpected = [One, Two, Three, Four]; @@ -110,11 +100,18 @@ public async Task CurrentThreadSequencerSkipsNestedWorkCancelledBeforeTheTrampol /// A task representing the asynchronous operation. [Test] public async Task ImmediateSequencerHonorsAbsoluteDueTime() - { - var start = Stopwatch.GetTimestamp(); - _ = Sequencer.Immediate.Schedule(Sequencer.Immediate.Now + AbsoluteDueOffset, static () => { }); - var elapsed = Stopwatch.GetElapsedTime(start); - await Assert.That(elapsed >= MinimumAbsoluteWait).IsTrue(); +{ + var dueTime = TimeSpan.FromTicks(One); + List events = []; + CallbackWorkItem item = new(() => events.Add("execute")); + TimeSpan? waited = null; + ImmediateSequencer.RunScheduled(item, dueTime, delay => + { + waited = delay; + events.Add("wait"); + }); + await Assert.That(waited).IsEqualTo(dueTime); + await Assert.That(events.SequenceEqual(["wait", "execute"])).IsTrue(); } /// Verifies virtual-clock work runs only after the clock reaches the due time. @@ -456,52 +453,21 @@ public async Task ScheduledItemNonGenericComparisonOrdersByDueTime() _ = Assert.Throws(() => ((IComparable)early).CompareTo("not-scheduled")); } - /// - /// Cancelling a scheduled item that is already running must not leak the resource the invocation produced. - /// The two operations race by nature, so this drives every interleaving and asserts the invariant that - /// holds across all of them: once both have finished, the invocation's resource is disposed. - /// + /// Cancellation during invocation releases the resource produced by the running action. /// A task representing the asynchronous operation. [Test] public async Task ScheduledItemCancelledDuringInvocationNeverLeaksTheInvocationResource() { - const int Iterations = 20_000; - - for (var iteration = 0; iteration < Iterations; iteration++) + RecordingDisposable resource = new(); + ScheduledItem? item = null; + item = ScheduledProbe.Create(One, () => { - RecordingDisposable resource = new(); - var invoked = false; - var item = ScheduledProbe.Create( - One, - () => - { - invoked = true; - return resource; - }); - - using Barrier barrier = new(Two); - var invoking = Task.Run(() => - { - barrier.SignalAndWait(); - item.Invoke(); - }); - var cancelling = Task.Run(() => - { - barrier.SignalAndWait(); - item.Cancel(); - }); - - await Task.WhenAll(invoking, cancelling); - - await Assert.That(item.IsDisposed).IsTrue(); - - // Either the cancellation beat the invocation entirely (no resource was ever created), or the - // invocation produced one — and in that case it must not have outlived the cancellation. The - // count is not pinned to exactly one: on the interleaving where the invocation installs the - // resource between the cancellation's two writes, both paths dispose it, and IDisposable.Dispose - // is required to tolerate that. - await Assert.That(!invoked || resource.DisposeCount >= 1).IsTrue(); - } + item!.Cancel(); + return resource; + }); + item.Invoke(); + await Assert.That(item.IsDisposed).IsTrue(); + await Assert.That(resource.DisposeCount).IsEqualTo(1); } /// Asserts the immediate sequencer validates its callbacks and runs each scheduling overload in order. @@ -521,7 +487,7 @@ private static async Task AssertImmediateSequencerValidatesAndRunsScheduledWork( immediateValues.Add(state); return EmptyDisposable.Instance; }).Dispose(); - Sequencer.Immediate.Schedule(Three, Sequencer.Immediate.Now.AddTicks(NegativeOne), (_, state) => + Sequencer.Immediate.Schedule(Three, DateTimeOffset.UnixEpoch, (_, state) => { immediateValues.Add(state); return EmptyDisposable.Instance; @@ -532,26 +498,29 @@ private static async Task AssertImmediateSequencerValidatesAndRunsScheduledWork( /// Asserts the task-pool and thread-pool sequencers validate their callbacks and run scheduled work. /// A task representing the asynchronous operation. private static async Task AssertPoolSequencersValidateAndRunScheduledWork() - { - _ = Assert.Throws(static () => TaskPoolSequencer.Instance.Schedule(One, null!)); - _ = Assert.Throws(static () => TaskPoolSequencer.Instance.Schedule(One, TimeSpan.Zero, null!)); - TaskCompletionSource taskPoolCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var taskPoolSubscription = TaskPoolSequencer.Instance.Schedule(Seven, (_, _) => +{ + InlineTaskScheduler scheduler = new(); + TaskPoolSequencer taskPool = new(new(scheduler)); + using ManualThreadPool pool = new(); + await Assert.That(() => taskPool.Schedule(One, null!)).Throws(); + await Assert.That(() => taskPool.Schedule(One, TimeSpan.Zero, null!)).Throws(); + var taskValue = 0; + using var taskSubscription = taskPool.Schedule(Seven, (_, value) => { - taskPoolCompletion.SetResult(); + taskValue = value; return EmptyDisposable.Instance; }); - await WaitForAsync(taskPoolCompletion.Task); - TaskCompletionSource threadPoolCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); - using var threadPoolSubscription = ThreadPoolSequencer.Instance.Schedule(Eight, TimeSpan.Zero, (_, _) => + await Assert.That(taskValue).IsEqualTo(Seven); + var threadValue = 0; + using var threadSubscription = pool.Sequencer.Schedule(Eight, TimeSpan.Zero, (_, value) => { - threadPoolCompletion.SetResult(); + threadValue = value; return EmptyDisposable.Instance; }); - await WaitForAsync(threadPoolCompletion.Task); - _ = Assert.Throws(static () => ThreadPoolSequencer.Instance.Schedule(One, null!)); - _ = Assert.Throws(static () => - ThreadPoolSequencer.Instance.Schedule(One, TimeSpan.Zero, null!)); + pool.RunReady(); + await Assert.That(threadValue).IsEqualTo(Eight); + await Assert.That(() => pool.Sequencer.Schedule(One, null!)).Throws(); + await Assert.That(() => pool.Sequencer.Schedule(One, TimeSpan.Zero, null!)).Throws(); } /// Asserts the synchronization-context sequencer validates its context and callbacks and runs scheduled work. @@ -580,8 +549,7 @@ private static async Task AssertSynchronizationContextSequencerValidatesAndRunsS SetCompletion(delayedSynchronizationCompletion, state); return EmptyDisposable.Instance; }); - var delayedValue = await delayedSynchronizationCompletion.Task - .WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + var delayedValue = await delayedSynchronizationCompletion.Task; var synchronizedValues = synchronizationValues.Append(delayedValue); await Assert.That(synchronizedValues.SequenceEqual(ExpectedOneTwo)).IsTrue(); } @@ -623,22 +591,6 @@ private static async Task AssertVirtualClockStartsAtItsInitialTime() await Assert.That(initialClock.Now).IsEqualTo(DateTimeOffset.UnixEpoch); } - /// Waits for a task with a bounded timeout. - /// The task to wait for. - /// A task representing the asynchronous operation. - /// did not complete within the bounded timeout. - private static async Task WaitForAsync(Task task) - { - var timeout = Task.Delay(TimeSpan.FromSeconds(TimeoutSeconds)); - var completed = await Task.WhenAny(task, timeout).ConfigureAwait(false); - if (completed == timeout) - { - throw new TimeoutException("Timed out waiting for scheduled work."); - } - - await task.ConfigureAwait(false); - } - /// Creates an invalid sequencer queue. private static void CreateInvalidSequencerQueue() { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs index 08acddf4..4f51298d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs @@ -31,12 +31,6 @@ public class SignalCreateTests /// Expected values for create-with-state tests. private static readonly int[] CreateWithStateExpected = [Third]; - /// A delay long enough that the subscription is always disposed before it elapses. - private static readonly TimeSpan NeverElapsingDelay = TimeSpan.FromSeconds(30); - - /// How long the test waits for the cancellation callback before failing. - private static readonly TimeSpan CancellationTimeout = TimeSpan.FromSeconds(5); - /// Creates the argument checking. [Test] public void Create_ArgumentChecking() @@ -251,7 +245,6 @@ public async Task AsyncCreateFactoriesAssignDisposablesAndForwardFailures() }); var subscription = created.Subscribe(values.Add); - await Task.Yield(); subscription.Dispose(); await Assert.That(values.SequenceEqual([CreatedValue])).IsTrue(); @@ -261,16 +254,16 @@ public async Task AsyncCreateFactoriesAssignDisposablesAndForwardFailures() InvalidOperationException expected = new("async-create"); _ = Signal.Create((_, _) => Task.FromException(expected)) .Subscribe(static _ => { }, error => observed = error); - await Task.Yield(); await Assert.That(observed).IsSameReferenceAs(expected); TaskCompletionSource canceled = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); var cancellable = Signal.Create(async (_, cancellationToken) => { try { - await Task.Delay(NeverElapsingDelay, cancellationToken).ConfigureAwait(false); + await release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -283,11 +276,10 @@ public async Task AsyncCreateFactoriesAssignDisposablesAndForwardFailures() var cancellableSubscription = cancellable.Subscribe(static _ => { }); cancellableSubscription.Dispose(); - await canceled.Task.WaitAsync(CancellationTimeout).ConfigureAwait(false); + await canceled.Task.ConfigureAwait(false); var nullDisposable = Signal.Create(static (_, _) => Task.FromResult(null!)); var nullSubscription = nullDisposable.Subscribe(static _ => { }); - await Task.Yield(); nullSubscription.Dispose(); _ = Assert.Throws(static () => @@ -304,7 +296,6 @@ public async Task AsyncDeferFactoriesEmitFailAndHonorCancellation() List values = []; _ = Signal.Defer(static () => Task.FromResult(Signal.Emit(CreatedValue))).Subscribe(values.Add); _ = Signal.Defer(static _ => Task.FromResult(Signal.Emit(First))).Subscribe(values.Add); - await Task.Yield(); await Assert.That(values.SequenceEqual([CreatedValue, First])).IsTrue(); @@ -312,19 +303,23 @@ public async Task AsyncDeferFactoriesEmitFailAndHonorCancellation() InvalidOperationException expected = new("defer"); _ = Signal.Defer(() => Task.FromException>(expected)) .Subscribe(static _ => { }, error => observed = error); - await Task.Yield(); await Assert.That(observed).IsSameReferenceAs(expected); - TaskCompletionSource> delayedFactory = new(TaskCreationOptions.RunContinuationsAsynchronously); - List canceledValues = []; - var deferred = Signal.Defer(_ => delayedFactory.Task); - var subscription = deferred.Subscribe(canceledValues.Add); - subscription.Dispose(); - delayedFactory.SetResult(Signal.Emit(Fourth)); - await Task.Yield(); + RecordingWitness canceled = new(); + AsyncSubscriptionLifetime lifetime = new(); + using CreateWitness witness = new(canceled); + witness.SetCancel(lifetime); + await AsyncDeferSignal.RunAsyncFactory( + _ => + { + lifetime.Dispose(); + return Task.FromResult(Signal.Emit(Fourth)); + }, + witness, + lifetime); - await Assert.That(canceledValues.Count).IsEqualTo(0); + await Assert.That(canceled.Values.Count).IsEqualTo(0); _ = Assert.Throws(static () => Signal.Defer((Func>>)null!)); _ = Assert.Throws(static () => diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalDeferTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalDeferTests.cs index eced4803..ffe45348 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalDeferTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalDeferTests.cs @@ -12,9 +12,6 @@ public sealed class SignalDeferTests /// Message carried by the failure a deferred source raises when an observer subscribes to it. private const string SubscribeFailureMessage = "defer-subscribe"; - /// Longest a test waits for the deferred subscription to report back. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Verifies deferred sources and blocking enumeration surface success, factory failure, and source failure paths. /// A task representing the asynchronous operation. [Test] @@ -47,11 +44,7 @@ await Assert.That(Signal.FromEnumerable([First, Second]).ToEnumerable().Sequence _ = Assert.Throws(static () => ((IObservable)null!).ToEnumerable()); } - /// - /// Verifies an asynchronously deferred source whose subscription throws surfaces that failure to the observer. - /// The factory succeeded, so the failure arrives on the subscribe step rather than the await, and it must still - /// reach the observer instead of faulting the fire-and-forget task that drives the deferred subscription. - /// + /// A subscription failure in an asynchronously produced source reaches the observer. /// A task representing the asynchronous operation. [Test] public async Task DeferAsyncSurfacesAFailureRaisedWhenSubscribingToTheProducedSource() @@ -70,7 +63,7 @@ public async Task DeferAsyncSurfacesAFailureRaisedWhenSubscribingToTheProducedSo _ = observed.TrySetResult(); }); - await Assert.That(observed.Task.WaitAsync(WaitTimeout)).ThrowsNothing(); + await Assert.That(observed.Task).ThrowsNothing(); await Assert.That(observedError!).IsSameReferenceAs(subscribeError); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs index 7190e35c..b6978cfa 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// Completes branch and contract coverage for factory, task, and terminal-task behavior. +/// Tests factories, task observation, and terminal task results. public partial class SignalFactoriesTests { /// Expected use-factory errors. @@ -24,7 +24,7 @@ public partial class SignalFactoriesTests private static readonly int[] ExpectedSingleAsyncValue = [1]; /// Exercises task, async-enumerable, and terminal task branches. - /// A task that completes when asynchronous coverage has run. + /// A task representing the asynchronous test. [Test] public async Task FactoriesTasksAndTerminalTasksCoverCancellationFaultAndEmptyBranches() { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs index 3a596db4..dd44f4e3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs @@ -285,11 +285,9 @@ 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] - [Timeout(30_000)] - public async Task AsyncEnumerableFactoryCancelsEnumeratorOnDispose(CancellationToken testToken) + public async Task AsyncEnumerableFactoryCancelsEnumeratorOnDispose() { var disposed = false; List values = []; @@ -311,7 +309,7 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to } } - var subscription = Signal.FromAsyncEnumerable(Values(testToken), testToken).Subscribe( + var subscription = Signal.FromAsyncEnumerable(Values()).Subscribe( value => { values.Add(value); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs index 2c768ce3..8387f54f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the factory overloads that name no sequencer and therefore run on the default one, together with -/// the degenerate inputs those factories have to fold away: an empty range and an uncancellable token. -/// +/// Verifies factory defaults, cancellation, and scheduled notifications. public partial class SignalFactoriesTests { /// The timeout used by the expiry factory test. @@ -49,15 +46,17 @@ public async Task FromEnumerableHonorsACancellableTokenAndIgnoresAnUncancellable await Assert.That(plainValues.SequenceEqual([One, Two, Three])).IsTrue(); } - /// Verifies the sequencer-free expiry factory fails a sequence that never terminates. + /// Running the scheduled expiry fails a silent source. /// A task representing the asynchronous operation. [Test] - public async Task ExpireWithoutASequencerFailsASilentSequence() - { - AwaitableWitness witness = new(); - using var subscription = Signal.Expire(Signal.Silent(), ShortExpiry).Subscribe(witness); - var error = await witness.FirstError; - await Assert.That(error).IsTypeOf(); + public async Task ExpireFailsASilentSequenceWhenTheDelayRuns() +{ + ManualSequencer sequencer = new(); + RecordingWitness witness = new(); + using var subscription = Signal.Expire(Signal.Silent(), ShortExpiry, sequencer).Subscribe(witness); + await Assert.That(witness.Errors.Count).IsEqualTo(0); + sequencer.RunPending(); + await Assert.That(witness.Errors[0]).IsTypeOf(); await Assert.That(witness.Errors.Count).IsEqualTo(1); } @@ -83,18 +82,16 @@ public async Task StartWithoutASequencerRunsTheWorkOnTheDefaultSequencer() await Assert.That(actionWitness.Values[0]).IsEqualTo(RxVoid.Default); } - /// Verifies the sequencer-free Every factory ticks on the default sequencer. + /// Each scheduled callback emits the next tick. /// A task representing the asynchronous operation. [Test] - public async Task EveryWithoutASequencerTicksOnTheDefaultSequencer() - { - AwaitableWitness witness = new(); - using (Signal.Every(ShortExpiry).Subscribe(witness)) - { - await witness.ValueCountReaching(Two); - } - - var ticks = witness.Values; - await Assert.That(ticks[1]).IsGreaterThan(ticks[0]); + public async Task EveryTicksWhenTheSequencerRuns() +{ + ManualSequencer sequencer = new(); + RecordingWitness witness = new(); + using var subscription = Signal.Every(ShortExpiry, sequencer).Subscribe(witness); + sequencer.RunPending(); + sequencer.RunPending(); + await Assert.That(witness.Values.SequenceEqual([0L, 1L])).IsTrue(); } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs index 7380390c..dac4e5f5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies how the run-once, sequence, loop, and timer factories dispatch through an -/// explicit sequencer: the work is deferred until the sequencer runs it, a current-thread sequencer routes the -/// subscription through the trampoline, and an external cancellation token is only wired up when it can be cancelled. -/// +/// Tests factory dispatch, recurring ticks, and cancellation through explicit sequencers. public partial class SignalFactoriesTests { /// The number of values taken from the infinite loop signal before it is torn down. @@ -21,11 +17,7 @@ public partial class SignalFactoriesTests /// The tick used as both the due time and the period of the virtual timers. private static readonly TimeSpan SingleTick = TimeSpan.FromTicks(1); - /// - /// Verifies a periodic timer keeps ticking under a sequencer that runs the first tick before its own - /// Schedule returns. The tick arms the next one, and the handle the outer call goes on to return must - /// not replace - and so cancel - that successor, which would leave the timer emitting a single value. - /// + /// An inline first tick retains the successor it schedules before the initial scheduling call returns. /// A task representing the asynchronous operation. [Test] public async Task TimerRetainsThePeriodicTickArmedByAnInlineFirstTick() @@ -182,8 +174,7 @@ await Assert.That(((IRequireCurrentThread)Signal.Loop(Seven)).IsRequiredSub RecordingWitness witness = new(); - // Loop drives itself through the current-thread trampoline, so the subscription has to be taken while the - // trampoline is already running; otherwise the handle that stops the recursion is never handed back. + // Subscribe inside the trampoline so Take receives its cancellation handle before emission starts. _ = Sequencer.CurrentThread.Schedule(() => Signal.Loop(Seven).Take(LoopTakeCount).Subscribe(witness)); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs index 86d01d7e..762810ef 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs @@ -40,9 +40,6 @@ public partial class SignalFactoriesTests /// The integer constant ninety-nine. private const int NinetyNine = 99; - /// The timeout in seconds used when waiting for asynchronous branches. - private const int TimeoutSeconds = 2; - /// The long constant zero. private const long ZeroLong = 0L; @@ -271,19 +268,13 @@ public async Task FromAsyncCancellableFactoryDisposalCancelsSubscriptionToken() /// A task representing the asynchronous operation. [Test] public async Task FromAsyncCancellableFactoryDisposalSuppressesIgnoredCancellationResult() - { - TaskCompletionSource subscribed = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource complete = new(TaskCreationOptions.RunContinuationsAsynchronously); +{ + AsyncSubscriptionLifetime lifetime = new(); AwaitableWitness observer = new(); - var subscription = Signal.FromAsync(token => - { - subscribed.SetResult(); - return complete.Task; - }).Subscribe(observer); - await subscribed.Task; - subscription.Dispose(); - complete.SetResult(NinetyNine); - await Task.Yield(); + using FromAsyncExternalCancellation cancellation = new(observer, lifetime, CancellationToken.None); + FromAsyncTaskObservation observation = new(observer, lifetime, cancellation, null); + lifetime.Dispose(); + observation.Observe(Task.FromResult(NinetyNine)); await Assert.That(observer.Values.Count).IsEqualTo(0); await Assert.That(observer.Errors.Count).IsEqualTo(0); await Assert.That(observer.Completions).IsEqualTo(0); @@ -318,24 +309,16 @@ public async Task FromAsyncCancellableFactoryExternalCancellationForwardsObserve /// A task representing the asynchronous operation. [Test] public async Task FromAsyncCancellableFactoryExternalCancellationForwardsObserverErrorWhenTaskIgnoresToken() - { +{ using CancellationTokenSource external = new(); - TaskCompletionSource subscribed = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource complete = new(TaskCreationOptions.RunContinuationsAsynchronously); + using AsyncSubscriptionLifetime lifetime = new(); AwaitableWitness observer = new(); - using var subscription = Signal.FromAsync( - token => - { - subscribed.SetResult(); - return complete.Task; - }, - external.Token).Subscribe(observer); - await subscribed.Task; + using FromAsyncExternalCancellation cancellation = new(observer, lifetime, external.Token); + FromAsyncTaskObservation observation = new(observer, lifetime, cancellation, null); + await Assert.That(cancellation.Start()).IsTrue(); await external.CancelAsync(); - var error = await observer.FirstError; - complete.SetResult(NinetyNine); - await Task.Yield(); - await Assert.That(error).IsTypeOf(); + observation.Observe(Task.FromResult(NinetyNine)); + await Assert.That(observer.Errors[0]).IsTypeOf(); await Assert.That(observer.Errors.Count).IsEqualTo(One); await Assert.That(observer.Values.Count).IsEqualTo(0); await Assert.That(observer.Completions).IsEqualTo(0); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs deleted file mode 100644 index 2a6e8383..00000000 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs +++ /dev/null @@ -1,1218 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using ReactiveUI.Primitives.Advanced; -using ReactiveUI.Primitives.Concurrency; -using ReactiveUI.Primitives.Signals; - -namespace ReactiveUI.Primitives.Tests; - -/// Tests for creating signals from tasks. -public class SignalFromTaskTest -{ - /// The value emitted by the simple from-task result test. - private const int EmittedValue = 2; - - /// The value produced by the successful pending task. - private const int SuccessValue = 7; - - /// The value produced by the disposed pending subscription. - private const int DisposedValue = 99; - - /// - /// Maximum wait for cancellation callbacks that are intentionally driven by timers. The callbacks fire on pool - /// threads, so on a saturated runner they can arrive far later than on an idle box; a cancellation that never - /// fires the callbacks never signals, so this window only has to outlast a slow runner and costs nothing when - /// the callbacks arrive promptly. - /// - private const int CancellationCallbackTimeoutMilliseconds = 30_000; - - /// Message carried by a failure an observer raises from inside a notification. - private const string ObserverFailureMessage = "observer failed"; - - /// Delay used before checking that a task has started. - private const int InitialDelayMilliseconds = 500; - - /// - /// Time spent performing synchronous cancellation cleanup work. Kept short so the - /// blocking does not occupy a thread-pool thread long - /// enough to delay the timer-driven cancellation callbacks the tests wait on (which - /// previously tipped the loaded CI runners over ). - /// - private const int CleanupWorkMilliseconds = 250; - - /// Delay used to wait for cancellation cleanup to finish. - private const int CancellationWaitDelayMilliseconds = 6000; - - /// Delay used by the command body. - private const int CommandDelayMilliseconds = 10_000; - - /// Exception message used by user exception tests. - private const string BreakExecutionMessage = "break execution"; - - /// Status text recorded when a command starts. - private const string StartedCommand = "started command"; - - /// Status text recorded when cancellation cleanup starts. - private const string StartingCancellingCommand = "starting cancelling command"; - - /// Status text recorded when cancellation cleanup finishes. - private const string FinishedCancellingCommand = "finished cancelling command"; - - /// Status text recorded when the command completes normally. - private const string FinishedCommandNormally = "finished command Normally"; - - /// Status text recorded by the exception handler. - private const string ExceptionShouldBeHere = "Exception Should Be here"; - - /// Status text recorded by the finalizer callback. - private const string ShouldAlwaysComeHere = "Should always come here."; - - /// How long a poll waits for an observed notification before failing. - private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(2); - - /// Covers from-task cancellation callback argument validation. - [Test] - public void FromTaskValidatesCancellationCallback() - { - var taskSignal = Signal.FromTask(static _ => Task.FromResult(1), Sequencer.Immediate); - try - { - _ = Assert.Throws(() => taskSignal.GetOperationCanceled(null!)); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// Covers from-task result emission and completion. - /// A task representing the asynchronous operation. - [Test] - public async Task FromTaskEmitsResult() - { - var taskSignal = Signal.FromTask(static _ => Task.FromResult(EmittedValue), Sequencer.Immediate); - try - { - List taskValues = []; - var taskCompleted = 0; - _ = taskSignal.Subscribe(taskValues.Add, static error => throw error, () => taskCompleted++); - await Assert.That(taskValues.SequenceEqual([EmittedValue])).IsTrue(); - await Assert.That(taskCompleted).IsEqualTo(1); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// Covers non-completed task factory continuations for success, fault, cancellation, and disposed subscriptions. - /// A task that completes when all continuations have been observed. - [Test] - public async Task TaskFactoryContinuationsCoverPendingTaskBranches() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - void AddValue(int value) => values.Enqueue(value); - void AddError(Exception error) => errors.Enqueue(error.GetType().Name); - - bool ObservedPendingBranches() - { - var observedValues = values.ToArray(); - var observedErrors = errors.ToArray(); - return Array.IndexOf(observedValues, SuccessValue) >= 0 - && Array.IndexOf(observedErrors, nameof(InvalidOperationException)) >= 0 - && Array.IndexOf(observedErrors, nameof(TaskCanceledException)) >= 0; - } - - TaskCompletionSource success = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource fault = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource canceled = new(TaskCreationOptions.RunContinuationsAsynchronously); - TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); - var disposedSubscription = Signal.FromTask(disposed.Task).Subscribe(_ => AddValue(DisposedValue), AddError); - disposedSubscription.Dispose(); - _ = Signal.FromTask(success.Task).Subscribe(AddValue, AddError); - _ = Signal.FromTask(fault.Task).Subscribe(AddValue, AddError); - _ = Signal.FromTask(canceled.Task).Subscribe(AddValue, AddError); - success.SetResult(SuccessValue); - fault.SetException(new InvalidOperationException("pending-fault")); - canceled.SetCanceled(new(true)); - disposed.SetResult(DisposedValue); - await TestPolling.SpinUntil(ObservedPendingBranches, PollTimeout).ConfigureAwait(false); - var finalValues = values.ToArray(); - var finalErrors = errors.ToArray(); - await Assert.That(finalValues.Length).IsEqualTo(1); - await Assert.That(finalValues[0]).IsEqualTo(SuccessValue); - await Assert.That(finalErrors).Contains(nameof(InvalidOperationException)); - await Assert.That(finalErrors).Contains(nameof(TaskCanceledException)); - } - - /// A synchronously completed task emits its result and completes through the immediate path. - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediateSynchronousSuccessEmitsResultAndCompletes() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var completed = 0; - var taskSignal = Signal.FromTask(static _ => Task.FromResult(SuccessValue), Sequencer.Immediate); - try - { - _ = taskSignal.Subscribe( - values.Enqueue, - error => errors.Enqueue(error.GetType().Name), - () => Interlocked.Increment(ref completed)); - await Assert.That(values.SequenceEqual([SuccessValue])).IsTrue(); - await Assert.That(errors).IsEmpty(); - await Assert.That(Volatile.Read(ref completed)).IsEqualTo(1); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A synchronously canceled task errors with a cancellation through the immediate path. - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediateSynchronousCanceledTaskErrors() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var taskSignal = Signal.FromTask(static _ => Task.FromCanceled(new(true)), Sequencer.Immediate); - try - { - _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); - await Assert.That(values).IsEmpty(); - await Assert.That(errors.SequenceEqual([nameof(OperationCanceledException)])).IsTrue(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A synchronously faulted task forwards the exception through the immediate path. - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediateSynchronousFaultedTaskForwardsError() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var taskSignal = - Signal.FromTask( - static _ => Task.FromException(new InvalidOperationException(BreakExecutionMessage)), - Sequencer.Immediate); - try - { - _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); - await Assert.That(values).IsEmpty(); - await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A throwing task factory forwards the exception through the immediate path. - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediateFactoryThrowForwardsError() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var taskSignal = Signal.FromTask( - static _ => throw new InvalidOperationException(BreakExecutionMessage), - Sequencer.Immediate); - try - { - _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); - await Assert.That(values).IsEmpty(); - await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A throwing task factory forwards the exception through the scheduled path. - /// A representing the asynchronous unit test. - [Test] - public async Task ScheduledFactoryThrowForwardsError() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var taskSignal = Signal.FromTask(static _ => throw new InvalidOperationException(BreakExecutionMessage)); - try - { - _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); - await TestPolling.SpinUntil(() => !errors.IsEmpty, PollTimeout).ConfigureAwait(false); - await Assert.That(values).IsEmpty(); - await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A synchronously completed task emits its result through the scheduled synchronous fast path. - /// A representing the asynchronous unit test. - [Test] - public async Task ScheduledSynchronousSuccessEmitsResultAndCompletes() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var completed = 0; - var taskSignal = Signal.FromTask(static _ => Task.FromResult(SuccessValue), Sequencer.CurrentThread); - try - { - _ = taskSignal.Subscribe( - values.Enqueue, - error => errors.Enqueue(error.GetType().Name), - () => Interlocked.Increment(ref completed)); - await TestPolling.SpinUntil(() => Volatile.Read(ref completed) == 1, PollTimeout) - .ConfigureAwait(false); - await Assert.That(values.SequenceEqual([SuccessValue])).IsTrue(); - await Assert.That(errors).IsEmpty(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A synchronously canceled task errors through the scheduled synchronous fast path. - /// A representing the asynchronous unit test. - [Test] - public async Task ScheduledSynchronousCanceledTaskErrors() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var taskSignal = Signal.FromTask(static _ => Task.FromCanceled(new(true)), Sequencer.CurrentThread); - try - { - _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); - await TestPolling.SpinUntil(() => !errors.IsEmpty, PollTimeout).ConfigureAwait(false); - await Assert.That(values).IsEmpty(); - await Assert.That(errors.SequenceEqual([nameof(OperationCanceledException)])).IsTrue(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// Disposing a subscription whose cancellation source was already disposed swallows the resulting error. - /// A representing the asynchronous unit test. - [Test] - public async Task DisposeAfterCancellationSourceDisposedSwallowsObjectDisposedException() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - var cts = new CancellationTokenSource(); - var taskSignal = Signal.FromTask(_ => gate.Task, Sequencer.Immediate, cts); - try - { - var subscription = - taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); - - // Dispose the cancellation source out from under the subscription, then dispose the - // subscription. The disposer wins the gate and calls Cancel on the disposed source, - // which must swallow the ObjectDisposedException. - cts.Dispose(); - subscription.Dispose(); - - gate.SetResult(SuccessValue); - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(false); - await Assert.That(values).IsEmpty(); - await Assert.That(errors).IsEmpty(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A pending task that faults after subscription forwards the exception via the continuation through the immediate path. - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediatePendingTaskFaultForwardsErrorViaContinuation() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - var taskSignal = Signal.FromTask(_ => gate.Task, Sequencer.Immediate); - try - { - _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); - gate.SetException(new InvalidOperationException(BreakExecutionMessage)); - await TestPolling.SpinUntil(() => !errors.IsEmpty, PollTimeout).ConfigureAwait(false); - await Assert.That(values).IsEmpty(); - await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// A pending task that completes after subscription emits the result via the continuation through the immediate path. - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediatePendingTaskSuccessEmitsResultViaContinuation() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var completed = 0; - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - var taskSignal = Signal.FromTask(_ => gate.Task, Sequencer.Immediate); - try - { - _ = taskSignal.Subscribe( - values.Enqueue, - error => errors.Enqueue(error.GetType().Name), - () => Interlocked.Increment(ref completed)); - gate.SetResult(SuccessValue); - await TestPolling.SpinUntil(() => Volatile.Read(ref completed) == 1, PollTimeout) - .ConfigureAwait(false); - await Assert.That(values.SequenceEqual([SuccessValue])).IsTrue(); - await Assert.That(errors).IsEmpty(); - await Assert.That(Volatile.Read(ref completed)).IsEqualTo(1); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// Disposing the immediate subscription before the awaited task completes suppresses the terminal notification. - /// A representing the asynchronous unit test. - [Test] - public async Task DisposeBeforeImmediateTaskCompletionSuppressesTerminalNotification() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var completed = 0; - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - var taskSignal = Signal.FromTask(_ => gate.Task, Sequencer.Immediate); - try - { - var subscription = taskSignal.Subscribe( - values.Enqueue, - error => errors.Enqueue(error.GetType().Name), - () => Interlocked.Increment(ref completed)); - - // Dispose while the awaited task is still pending, then release the continuation. - subscription.Dispose(); - gate.SetResult(SuccessValue); - - // Give the continuation ample time to run; it must observe the dispose and stay silent. - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(false); - await Assert.That(values).IsEmpty(); - await Assert.That(errors).IsEmpty(); - await Assert.That(Volatile.Read(ref completed)).IsEqualTo(0); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// Disposing the scheduled subscription before the awaited task completes suppresses the terminal notification. - /// A representing the asynchronous unit test. - [Test] - public async Task DisposeBeforeScheduledTaskCompletionSuppressesTerminalNotification() - { - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - var completed = 0; - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - var taskSignal = Signal.FromTask(_ => gate.Task); - try - { - var subscription = taskSignal.Subscribe( - values.Enqueue, - error => errors.Enqueue(error.GetType().Name), - () => Interlocked.Increment(ref completed)); - - // Dispose while the awaited task is still pending, then release the continuation. - subscription.Dispose(); - gate.SetResult(SuccessValue); - - // Give the continuation ample time to run; it must observe the dispose and stay silent. - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(false); - await Assert.That(values).IsEmpty(); - await Assert.That(errors).IsEmpty(); - await Assert.That(Volatile.Read(ref completed)).IsEqualTo(0); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// The non-generic RxVoid factory honors the scheduler overload and emits a completion. - /// A representing the asynchronous unit test. - [Test] - public async Task RxVoidFactoryWithSchedulerEmitsCompletion() - { - var completed = 0; - var taskSignal = Signal.FromTask(static _ => Task.FromResult(RxVoid.Default), Sequencer.Immediate); - try - { - _ = taskSignal.Subscribe(static _ => { }, static error => throw error, () => Interlocked.Increment(ref completed)); - await Assert.That(Volatile.Read(ref completed)).IsEqualTo(1); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// The immediate signal reports cancellation and disposal state and fires the cancellation callback once disposed. - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediateSignalReportsStateAndFiresCancellationCallbackOnDispose() - { - TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - var taskSignal = Signal.FromTask(_ => gate.Task, Sequencer.Immediate); - var canceledRaised = 0; - await Assert.That(taskSignal.IsCancellationRequested).IsFalse(); - await Assert.That(taskSignal.IsDisposed).IsFalse(); - taskSignal.GetOperationCanceled(Witness.Create(_ => Interlocked.Increment(ref canceledRaised))); - - ((IDisposable)taskSignal).Dispose(); - - await Assert.That(taskSignal.IsDisposed).IsTrue(); - await Assert.That(taskSignal.IsCancellationRequested).IsTrue(); - await TestPolling.SpinUntil(() => Volatile.Read(ref canceledRaised) == 1, PollTimeout) - .ConfigureAwait(false); - await Assert.That(Volatile.Read(ref canceledRaised)).IsEqualTo(1); - - // A second dispose is a no-op (covers the already-disposed early return). - ((IDisposable)taskSignal).Dispose(); - await Assert.That(taskSignal.IsDisposed).IsTrue(); - } - - /// Subscribing to a disposed immediate signal throws. - [Test] - public void ImmediateSignalSubscribeAfterDisposeThrows() - { - var taskSignal = Signal.FromTask(static _ => Task.FromResult(SuccessValue), Sequencer.Immediate); - ((IDisposable)taskSignal).Dispose(); - _ = Assert.Throws(() => taskSignal.Subscribe(static _ => { })); - } - - /// - /// Signals from task handles user exceptions. The command body is released by a gate rather than a timer, so the - /// subscription is only torn down once the failure has already travelled the whole chain; a dispose that lands - /// after the terminal must leave the cancellation path untouched. - /// - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTaskHandlesUserExceptions() - { - 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); - _ = executionStarted.TrySetResult(); - await releaseExecution.Task.WaitAsync(cts.Token) - .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - throw new InvalidOperationException(BreakExecutionMessage); - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => - { - RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); - _ = finallyCompleted.TrySetResult(); - }); - var result = false; - var subscription = fixture.Subscribe(_ => result = true); - await executionStarted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - _ = releaseExecution.TrySetResult(); - await finallyCompleted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); - subscription.Dispose(); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(StartingCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).Contains(ShouldAlwaysComeHere); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).Contains(ExceptionShouldBeHere); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCommandNormally); - await Assert.That(result).IsFalse(); - } - - /// Signals from task handles cancellation. - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTaskHandlesCancellation() - { - StatusTrail statusTrail = new(); - var position = 0; - var fixture = Signal.FromTask(async cts => - { - RecordStatus(statusTrail, ref position, StartedCommand); - await Task.Delay(CommandDelayMilliseconds, cts.Token) - .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); - var result = false; - var subscription = fixture.Subscribe(_ => result = true); - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - subscription.Dispose(); - await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).Contains(StartingCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).Contains(ShouldAlwaysComeHere); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCommandNormally); - await Assert.That(result).IsFalse(); - } - - /// Signals from task handles token cancellation. - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTaskHandlesTokenCancellation() - { - 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); - _ = cancellationReady.TrySetResult(cts); - await Task.Delay(Timeout.InfiniteTimeSpan, cts.Token).HandleCancellation(() => - { - RecordCancellationCleanup(statusTrail, ref position); - _ = cleanupCompleted.TrySetResult(); - }).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => - { - RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); - _ = finallyCompleted.TrySetResult(); - }); - var result = false; - using var subscription = fixture.Subscribe(_ => result = true); - var cancellationSource = await cancellationReady.Task.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); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCommandNormally); - await Assert.That(result).IsFalse(); - } - - /// Signals from task handles cancellation in base. - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTaskHandlesCancellationInBase() - { - StatusTrail statusTrail = new(); - var position = 0; - var fixture = Signal.FromTask(async cts => - { - RecordStatus(statusTrail, ref position, StartedCommand); - await Task.Delay(CommandDelayMilliseconds, cts.Token).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); - var subscription = fixture.Subscribe(); - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - subscription.Dispose(); - await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCommandNormally); - await Assert.That(statusTrail.LastMessage).IsEqualTo(ShouldAlwaysComeHere); - } - - /// - /// Signals from task handles completion. The command body is released by a gate rather than a timer, so the - /// assertions run once the terminal cleanup has actually happened instead of once a wall-clock window is judged - /// long enough for it. - /// - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTaskHandlesCompletion() - { - 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); - _ = executionStarted.TrySetResult(); - await releaseExecution.Task.WaitAsync(cts.Token) - .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => - { - RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); - _ = finallyCompleted.TrySetResult(); - }); - var result = false; - using var subscription = fixture.Subscribe(_ => result = true); - await executionStarted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - _ = releaseExecution.TrySetResult(); - await finallyCompleted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(StartingCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCommandNormally); - await Assert.That(statusTrail.LastMessage).IsEqualTo(ShouldAlwaysComeHere); - await Assert.That(result).IsTrue(); - } - - /// Signals from task t handles user exceptions. - /// A representing the asynchronous unit test. - [Test] - 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); - _ = executionStarted.TrySetResult(); - await releaseExecution.Task.WaitAsync(cts.Token) - .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - throw new InvalidOperationException(BreakExecutionMessage); - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => - { - RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); - _ = finallyCompleted.TrySetResult(); - }); - var result = false; - using var subscription = fixture.Subscribe(_ => result = true); - await executionStarted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - _ = 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); - await Assert.That(StatusMessages(statusTrail)).Contains(ExceptionShouldBeHere); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCommandNormally); - await Assert.That(result).IsFalse(); - } - - /// Signals from task t handles cancellation. - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTask_T_HandlesCancellation() - { - StatusTrail statusTrail = new(); - var position = 0; - var fixture = Signal.FromTask(async cts => - { - RecordStatus(statusTrail, ref position, StartedCommand); - await Task.Delay(CommandDelayMilliseconds, cts.Token) - .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); - var result = false; - var subscription = fixture.Subscribe(_ => result = true); - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - subscription.Dispose(); - await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).Contains(StartingCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).Contains(ShouldAlwaysComeHere); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCommandNormally); - await Assert.That(result).IsFalse(); - } - - /// Signals from task t handles token cancellation. - /// A representing the asynchronous unit test. - [Test] - 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); - _ = cancellationReady.TrySetResult(cts); - await Task.Delay(Timeout.InfiniteTimeSpan, cts.Token).HandleCancellation(() => - { - RecordCancellationCleanup(statusTrail, ref position); - _ = cleanupCompleted.TrySetResult(); - }).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => - { - RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); - _ = finallyCompleted.TrySetResult(); - }); - var result = false; - using var subscription = fixture.Subscribe(_ => result = 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); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCommandNormally); - await Assert.That(result).IsFalse(); - } - - /// Signals from task t handles cancellation in base. - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTask_T_HandlesCancellationInBase() - { - StatusTrail statusTrail = new(); - var position = 0; - var fixture = Signal.FromTask(async cts => - { - RecordStatus(statusTrail, ref position, StartedCommand); - await Task.Delay(CommandDelayMilliseconds, cts.Token).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); - var subscription = fixture.Subscribe(); - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - subscription.Dispose(); - await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCommandNormally); - await Assert.That(statusTrail.LastMessage).IsEqualTo(ShouldAlwaysComeHere); - } - - /// - /// Signals from task t handles completion. Like its non-generic counterpart the command body is released by a - /// gate rather than a timer, so the assertions run once the terminal cleanup has actually happened. - /// - /// A representing the asynchronous unit test. - [Test] - public async Task SignalFromTask_T_HandlesCompletion() - { - 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); - _ = executionStarted.TrySetResult(); - await releaseExecution.Task.WaitAsync(cts.Token) - .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)).ConfigureAwait(true); - if (!cts.IsCancellationRequested) - { - RecordStatus(statusTrail, ref position, FinishedCommandNormally); - } - - return RxVoid.Default; - }).Recover(ex => - { - RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); - return Signal.Fail(ex); - }).OnCleanup(() => - { - RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); - _ = finallyCompleted.TrySetResult(); - }); - var result = false; - using var subscription = fixture.Subscribe(_ => result = true); - await executionStarted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - _ = releaseExecution.TrySetResult(); - await finallyCompleted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(StartingCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCancellingCommand); - await Assert.That(StatusMessages(statusTrail)).Contains(FinishedCommandNormally); - await Assert.That(statusTrail.LastMessage).IsEqualTo(ShouldAlwaysComeHere); - await Assert.That(result).IsTrue(); - } - - /// - /// Verifies the immediate task signal exposes itself as its own source and owns a cancellation source, and - /// that a task whose token was cancelled before it produced its result errors instead of emitting it. - /// - /// A representing the asynchronous unit test. - [Test] - public async Task ImmediateTaskSignalErrorsWhenTheTokenIsCancelledBeforeTheResultArrives() - { - TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); - var taskSignal = Signal.FromTask(_ => completion.Task, Sequencer.Immediate); - try - { - await Assert.That(taskSignal.CancellationTokenSource).IsNotNull(); - await Assert.That(ReferenceEquals(taskSignal.Source, taskSignal)).IsTrue(); - ConcurrentQueue values = new(); - ConcurrentQueue errors = new(); - _ = taskSignal.Subscribe(values.Enqueue, errors.Enqueue, static () => { }); - await taskSignal.CancellationTokenSource!.CancelAsync(); - completion.SetResult(SuccessValue); - await TestPolling.SpinUntil(() => !errors.IsEmpty, PollTimeout).ConfigureAwait(false); - await Assert.That(errors.Count).IsEqualTo(1); - _ = errors.TryPeek(out var error); - await Assert.That(error!).IsTypeOf(); - await Assert.That(values).IsEmpty(); - } - finally - { - (taskSignal as IDisposable)?.Dispose(); - } - } - - /// - /// Verifies a task that faults after its subscription was disposed notifies nobody. The task continuation still - /// runs, and it must find the subscription already stopped rather than push a terminal at an observer that has - /// unsubscribed. - /// - /// A representing the asynchronous operation. - [Test] - public async Task FromTaskDropsAFaultRaisedAfterTheSubscriptionWasDisposed() - { - TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); - RecordingObserver observer = new(); - - var subscription = Signal.FromTask(pending.Task).Subscribe(observer); - subscription.Dispose(); - - pending.SetException(new InvalidOperationException(ObserverFailureMessage)); - - // The continuation runs on the thread pool; give it room to reach the stopped subscription. - await Task.Delay(PollTimeout); - - await Assert.That(observer.ErrorCount).IsEqualTo(0); - await Assert.That(observer.NextCount).IsEqualTo(0); - await Assert.That(observer.CompletedCount).IsEqualTo(0); - } - - /// - /// Verifies an observer that throws out of its own does not get a second - /// terminal. The task's terminal was already claimed before the value was pushed, so the observer's failure is - /// swallowed rather than turned into an the observer never expected. - /// - /// A representing the asynchronous operation. - [Test] - public async Task FromAsyncSwallowsAFailureTheObserverRaisedFromOnNext() - { - TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); - ThrowingObserver observer = new(new InvalidOperationException(ObserverFailureMessage)); - - using var subscription = Signal.FromAsync(_ => pending.Task).Subscribe(observer); - pending.SetResult(SuccessValue); - - await Assert.That(observer.Observed.Task.WaitAsync(PollTimeout)).ThrowsNothing(); - await Task.Delay(PollTimeout); - - await Assert.That(observer.NextCount).IsEqualTo(1); - await Assert.That(observer.ErrorCount).IsEqualTo(0); - await Assert.That(observer.CompletedCount).IsEqualTo(0); - } - - /// - /// Verifies an observer that disposes its own subscription and then throws is met with silence. Disposal owns that - /// cancellation path, so the failure must not be reported back down a subscription the observer has just torn down. - /// - /// A representing the asynchronous operation. - [Test] - public async Task FromAsyncStaysSilentWhenTheObserverDisposesItselfAndThenThrows() - { - TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); - SelfDisposingThrowingObserver observer = new(new InvalidOperationException(ObserverFailureMessage)); - - observer.Subscription = Signal.FromAsync(_ => pending.Task).Subscribe(observer); - pending.SetResult(SuccessValue); - - await Assert.That(observer.Observed.Task.WaitAsync(PollTimeout)).ThrowsNothing(); - await Task.Delay(PollTimeout); - - await Assert.That(observer.NextCount).IsEqualTo(1); - await Assert.That(observer.ErrorCount).IsEqualTo(0); - await Assert.That(observer.CompletedCount).IsEqualTo(0); - } - - /// - /// Verifies the external-cancellation overload still runs the factory when the caller passes a token that can never - /// be cancelled. There is nothing to register against, so the subscription must start rather than register a - /// callback on a token that will never fire. - /// - /// A representing the asynchronous operation. - [Test] - public async Task FromAsyncWithAnUncancellableTokenStillRunsTheFactory() - { - RecordingObserver observer = new(); - - using var subscription = Signal - .FromAsync(static _ => Task.FromResult(SuccessValue), CancellationToken.None) - .Subscribe(observer); - - await Assert.That(observer.NextCount).IsEqualTo(1); - await Assert.That(observer.LastValue).IsEqualTo(SuccessValue); - await Assert.That(observer.CompletedCount).IsEqualTo(1); - await Assert.That(observer.ErrorCount).IsEqualTo(0); - } - - /// Gets the recorded status messages. - /// The status trail. - /// The recorded messages. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string[] StatusMessages(StatusTrail statusTrail) => statusTrail.Messages(); - - /// Awaits both timer-driven cancellation callbacks, or fails once the generous window closes. - /// Completes when the cancellation cleanup callback has run. - /// Completes when the terminal cleanup callback has run. - /// A representing the asynchronous operation. - /// Neither callback had run once the generous wait window closed. - /// - /// The callbacks are driven by timers and run on pool threads, so the wait must stay asynchronous: blocking a pool - /// thread would starve the very chain it is waiting on. It also must not decide the outcome from which continuation - /// the pool dequeues first — Task.WhenAny reports whichever continuation ran first, not whichever task - /// completed first, so on a saturated runner the timeout can be reported as the winner even though the callbacks - /// already fired. This awaits the race for liveness but then decides from the callback tasks' own completion state, - /// which flips synchronously and no pool pressure can misreport. - /// A cancellation that never fires the callbacks never completes, so the generous window still fails on a real hang. - /// - private static async Task WaitForCancellationCallbacks(Task cleanupCompleted, Task finallyCompleted) - { - var callbacks = Task.WhenAll(cleanupCompleted, finallyCompleted); - _ = await Task.WhenAny(callbacks, Task.Delay(CancellationCallbackTimeoutMilliseconds)).ConfigureAwait(false); - if (!cleanupCompleted.IsCompleted || !finallyCompleted.IsCompleted) - { - throw new TimeoutException( - $"Timed out after {CancellationCallbackTimeoutMilliseconds}ms waiting for cancellation callbacks."); - } - - await callbacks.ConfigureAwait(false); - } - - /// Records a status message. - /// The status trail. - /// The current status position. - /// The message to record. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void RecordStatus(StatusTrail statusTrail, ref int position, string message) => - statusTrail.Add(ref position, message); - - /// Records synchronous cancellation cleanup. - /// The status trail. - /// The current status position. - private static void RecordCancellationCleanup(StatusTrail statusTrail, ref int position) - { - RecordStatus(statusTrail, ref position, StartingCancellingCommand); - Thread.Sleep(CleanupWorkMilliseconds); - RecordStatus(statusTrail, ref position, FinishedCancellingCommand); - } - - /// Observer that counts the notifications it received. - private class RecordingObserver : IObserver - { - /// Gets the number of values received. - public int NextCount { get; private set; } - - /// Gets the number of failures received. - public int ErrorCount { get; private set; } - - /// Gets the number of completions received. - public int CompletedCount { get; private set; } - - /// Gets the most recently received value. - public int LastValue { get; private set; } - - /// - public virtual void OnNext(int value) - { - NextCount++; - LastValue = value; - } - - /// - public void OnError(Exception error) => ErrorCount++; - - /// - public void OnCompleted() => CompletedCount++; - } - - /// Observer that throws out of after recording the value. - /// The failure the observer raises. - private class ThrowingObserver(Exception failure) : RecordingObserver - { - /// The failure raised from the value notification. - private readonly Exception _failure = failure; - - /// Gets a task that completes once the observer has seen its value. - public TaskCompletionSource Observed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// - public override void OnNext(int value) - { - base.OnNext(value); - _ = Observed.TrySetResult(); - throw _failure; - } - } - - /// Observer that tears its own subscription down before it throws. - /// The failure the observer raises. - private sealed class SelfDisposingThrowingObserver(Exception failure) : ThrowingObserver(failure) - { - /// Gets or sets the subscription the observer disposes from inside its value notification. - public IDisposable? Subscription { get; set; } - - /// - public override void OnNext(int value) - { - Subscription?.Dispose(); - base.OnNext(value); - } - } - - /// Thread-safe status trail used by async cancellation tests. - private sealed class StatusTrail - { - /// Synchronizes access to the recorded statuses. - private readonly Lock _gate = new(); - - /// Stores the recorded status positions and messages. - private readonly List<(int Position, string Message)> _items = []; - - /// Gets the last recorded status message. - public string LastMessage - { - get - { - lock (_gate) - { - return _items[^1].Message; - } - } - } - - /// Adds a status message. - /// The current status position. - /// The message. - public void Add(ref int position, string message) - { - lock (_gate) - { - _items.Add((position, message)); - position++; - } - } - - /// Creates a snapshot of the recorded messages. - /// The message snapshot. - public string[] Messages() - { - lock (_gate) - { - var messages = new string[_items.Count]; - for (var i = 0; i < messages.Length; i++) - { - messages[i] = _items[i].Message; - } - - return messages; - } - } - } -} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs index bbf58491..0819b57c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs @@ -19,12 +19,7 @@ public partial class SignalOperatorMixinsTests /// The late terminal error message. private const string LateErrorMessage = "late"; - /// - /// Map and Keep are pass-through wrappers: neither adds a thread affinity of its own, so each must report - /// exactly the current-thread requirement of the source it wraps. Reporting for a - /// free-threaded source would needlessly pin subscription; reporting for a pinned - /// one would subscribe on the wrong thread. - /// + /// Map and Keep preserve their source's current-thread subscription requirement. /// A task representing the asynchronous operation. [Test] public async Task MapAndKeepSignalsInheritTheCurrentThreadRequirementOfTheirSource() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs index d52ee57b..a51573d0 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Tests; -/// Completes branch and contract coverage for primitive signal operators and aliases. +/// Tests signal operators and their aliases. public partial class SignalOperatorMixinsTests { /// The integer constant eight. @@ -25,7 +25,7 @@ public partial class SignalOperatorMixinsTests /// The integer constant twenty-one. private const int TwentyOne = 21; - /// The integer constant forty-two used by behavior coverage. + /// The integer constant forty-two. private const int BehaviorFortyTwo = 42; /// Expected operator values. @@ -100,7 +100,7 @@ public partial class SignalOperatorMixinsTests /// Expected timestamp values. private static readonly int[] ExpectedTimestampValues = [Eight, Nine]; - /// Validates null guard coverage across public factories, operators, and observers. + /// Factories, operators, and observers reject null arguments. /// A task representing the asynchronous operation. [Test] public async Task NullGuardsCoverPublicFactoryOperatorAndObserverContracts() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs index 19125952..76e58c6a 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs @@ -2,9 +2,7 @@ // 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.Concurrent; using System.Runtime.CompilerServices; -using System.Threading; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Signals; @@ -13,12 +11,6 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies delayed signal operator behavior. public partial class SignalOperatorMixinsTests { - /// Observation window used to verify disposal waits for in-flight delivery. - private const int DisposeObservationMilliseconds = 200; - - /// Generous bound for the in-flight delivery to begin, tolerant of a saturated thread pool. - private const int InflightDeliveryTimeoutSeconds = 30; - /// Verifies shift uses one ordered drain for a burst of delayed notifications. /// A task representing the asynchronous operation. [Test] @@ -47,11 +39,7 @@ public async Task ShiftUsesSingleSerializedDrainForQueuedNotifications() await Assert.That(sequencer.ScheduledCount).IsEqualTo(0); } - /// - /// Verifies the drain timer a tick reschedules for itself survives the scheduling call that ran that tick. - /// A sequencer may run the drain before Schedule returns; the tick then finds the queued value is not - /// due yet and arms the next drain, and that successor must not be cancelled when the outer call returns. - /// + /// An inline drain retains the successor it schedules before the initial scheduling call returns. /// A task representing the asynchronous operation. [Test] public async Task ShiftRetainsTheDrainTimerArmedByAnInlineTick() @@ -74,63 +62,35 @@ public async Task ShiftRetainsTheDrainTimerArmedByAnInlineTick() /// Verifies that dispose waits for in-flight delivery and blocks queued notifications. /// A task representing the asynchronous operation. [Test] - public async Task ShiftDisposeWaitsForInflightDeliveryAndDisallowsFurtherDelivery() - { - var dueTime = TimeSpan.FromMilliseconds(One); - - // Drain on dedicated threads so a saturated thread pool cannot stall the - // blocking in-flight delivery this test relies on; only the brief timer - // callback stays on the pool. - TaskPoolSequencer sequencer = new(new( - CancellationToken.None, - TaskCreationOptions.LongRunning, - TaskContinuationOptions.None, - TaskScheduler.Default)); + public async Task ShiftDisposalClaimDuringDeliverySuppressesQueuedNotifications() +{ + var dueTime = TimeSpan.FromTicks(One); + RecordingSequencer sequencer = new(DateTimeOffset.UnixEpoch); Signal source = new(); - using ManualResetEventSlim onNextEntered = new(false); - using ManualResetEventSlim onNextRelease = new(false); - ConcurrentQueue values = []; + List values = []; var completed = 0; - var delivered = 0; - - using var subscription = source - .Shift(dueTime, sequencer) - .Subscribe( - value => - { - if (Interlocked.Increment(ref delivered) == 1) - { - onNextEntered.Set(); - } - - onNextRelease.Wait(); - values.Enqueue(value); - }, - static _ => { }, - () => Interlocked.Increment(ref completed)); - + var claimed = false; + LinqExtensions.ShiftCoordinator? coordinator = null; + var observer = new DelegateWitness( + value => + { + values.Add(value); + claimed = coordinator!.TryBeginDispose(); + }, + static _ => { }, + () => completed++); + coordinator = new(source, dueTime, sequencer, observer); + using var subscription = coordinator.Run(); source.OnNext(One); source.OnNext(Two); source.OnCompleted(); - - await Assert.That(onNextEntered.Wait(TimeSpan.FromSeconds(InflightDeliveryTimeoutSeconds))).IsTrue(); - - var disposeTask = Task.Factory.StartNew( - subscription.Dispose, - CancellationToken.None, - TaskCreationOptions.LongRunning, - TaskScheduler.Default); - var disposeObservation = Task.Delay(TimeSpan.FromMilliseconds(DisposeObservationMilliseconds)); - var disposeCompleted = await Task.WhenAny(disposeTask, disposeObservation) == disposeTask; - - await Assert.That(disposeCompleted).IsFalse(); - - onNextRelease.Set(); - await disposeTask; - - await Assert.That(values.ToArray().SequenceEqual([One])).IsTrue(); - - await Assert.That(Volatile.Read(ref completed)).IsEqualTo(0); + sequencer.AdvanceBy(dueTime); + sequencer.RunNext(); + coordinator.ReleaseSubscriptions(); + await Assert.That(claimed).IsTrue(); + await Assert.That(coordinator.TryBeginDispose()).IsFalse(); + await Assert.That(values.SequenceEqual([One])).IsTrue(); + await Assert.That(completed).IsEqualTo(0); } /// Verifies a delayed error is forwarded after the queued values that precede it. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs index c523fbfd..48005413 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs @@ -6,13 +6,12 @@ using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Concurrency; -using ReactiveUI.Primitives.Core; using ReactiveUI.Primitives.Disposables; using ReactiveUI.Primitives.Signals; namespace ReactiveUI.Primitives.Tests; -/// Verifies deterministic operator, factory, coordinator, and timing branch coverage. +/// Tests operator and coordinator notification ordering. public partial class SignalOperatorMixinsTests { /// The integer constant ten. @@ -36,18 +35,12 @@ public partial class SignalOperatorMixinsTests /// The integer constant thirty-two. private const int ThirtyTwo = 32; - /// Iterations used to stress the work item invoke/dispose race. - private const int RaceIterations = 256; - /// The long constant two. private const long TwoLong = 2L; /// The long constant three. private const long ThreeLong = 3L; - /// The number of threads that rendezvous before the disposal race starts. - private const int RacingThreadCount = 2; - /// A fixed deterministic timestamp used in place of the current time. private static readonly DateTimeOffset FixedTimestamp = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); @@ -473,7 +466,7 @@ private static async Task VerifyAsyncEnumerableShiftAndExpireAsync() asyncValues.Add, ex => asyncCompleted.TrySetException(ex), () => asyncCompleted.TrySetResult(null)); - await asyncCompleted.Task.WaitAsync(TimeSpan.FromSeconds(Five)).ConfigureAwait(false); + await asyncCompleted.Task.ConfigureAwait(false); int[] expectedAsyncValues = [0, One, Two]; await Assert.That(asyncValues.SequenceEqual(expectedAsyncValues)).IsTrue(); var exact = await Signal.FromAsyncEnumerable(AsyncValues(Sixteen)).CollectArrayAsync().ConfigureAwait(false); @@ -690,10 +683,7 @@ private static async Task VerifySwitchDefersCompletionUntilTheCurrentInnerFinish await Assert.That(staleCompleted.Completed).IsEqualTo(1); } - /// - /// Verifies an inner error makes the coordinator terminal even though the outer source is still live, so a - /// later switch, a later outer error, and a later outer completion are all gated. - /// + /// Checks that an inner error suppresses later outer values and terminal notifications. /// A task representing the asynchronous operation. private static async Task VerifySwitchGatesEveryNotificationAfterAnInnerError() { @@ -837,303 +827,6 @@ private static async Task VerifyCalmAppendAndForkJoinBranches() await Assert.That(forkRightFirst.Completed).IsEqualTo(1); } - /// Verifies the timestamp operator immediate and clock-backed branches. - /// A task representing the asynchronous operation. - private static async Task VerifyTimestampBranches() - { - RecordingWitness> immediateMoments = new(); - Signal.Sequence(One, Three).Timestamp(Sequencer.Immediate).Subscribe(immediateMoments).Dispose(); - IEnumerable expectedImmediateMoments = [One, Two, Three]; - int[] immediateMomentValues = - [immediateMoments.Values[0].Value, immediateMoments.Values[1].Value, immediateMoments.Values[Two].Value]; - await Assert.That(immediateMomentValues.SequenceEqual(expectedImmediateMoments)).IsTrue(); - await Assert.That(immediateMoments.Completed).IsEqualTo(1); - List> clockMoments = []; - var clockMomentCompleted = 0; - _ = Signal.Sequence(Four, Two).Timestamp(new VirtualClock(DateTimeOffset.UnixEpoch)) - .Subscribe(clockMoments.Add, static ex => throw ex, () => clockMomentCompleted++); - IEnumerable expectedClockMoments = [Four, Five]; - int[] clockMomentValues = [clockMoments[0].Value, clockMoments[1].Value]; - await Assert.That(clockMomentValues.SequenceEqual(expectedClockMoments)).IsTrue(); - await Assert.That(clockMomentCompleted).IsEqualTo(1); - List> immediateMomentActions = []; - var immediateMomentCompleted = 0; - var immediateTimestampSignal = - (IInlineSignal>)Signal.Sequence(Two, Two).Timestamp(Sequencer.Immediate); - immediateTimestampSignal.Subscribe(immediateMomentActions.Add, static ex => throw ex, () => immediateMomentCompleted++) - .Dispose(); - IEnumerable expectedImmediateMomentActions = [Two, Three]; - int[] immediateMomentActionValues = [immediateMomentActions[0].Value, immediateMomentActions[1].Value]; - await Assert.That(immediateMomentActionValues.SequenceEqual(expectedImmediateMomentActions)).IsTrue(); - await Assert.That(immediateMomentCompleted).IsEqualTo(1); - RecordingWitness> clockMomentObserver = new(); - var clockTimestampSignal = - (IInlineSignal>)Signal.Sequence(Two, Two).Timestamp(new VirtualClock(DateTimeOffset.UnixEpoch)); - clockTimestampSignal.Subscribe(clockMomentObserver).Dispose(); - IEnumerable expectedClockMomentObserver = [Two, Three]; - int[] clockMomentObserverValues = [clockMomentObserver.Values[0].Value, clockMomentObserver.Values[1].Value]; - await Assert.That(clockMomentObserverValues.SequenceEqual(expectedClockMomentObserver)).IsTrue(); - await Assert.That(clockMomentObserver.Completed).IsEqualTo(1); - _ = Assert.Throws(() => - immediateTimestampSignal.Subscribe((IObserver>)null!)); - _ = Assert.Throws(() => - immediateTimestampSignal.Subscribe((Action>)null!, static _ => { }, static () => { })); - } - - /// Verifies the time-interval operator immediate and clock-backed branches. - /// A task representing the asynchronous operation. - private static async Task VerifyTimeIntervalBranches() - { - RecordingWitness> immediateIntervals = new(); - Signal.Sequence(One, Three).TimeInterval(Sequencer.Immediate).Subscribe(immediateIntervals).Dispose(); - IEnumerable expectedImmediateIntervals = [One, Two, Three]; - int[] immediateIntervalValues = - [ - immediateIntervals.Values[0].Value, immediateIntervals.Values[1].Value, immediateIntervals.Values[Two].Value - ]; - await Assert.That(immediateIntervalValues.SequenceEqual(expectedImmediateIntervals)).IsTrue(); - await Assert.That(immediateIntervals.Values[0].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(immediateIntervals.Values[1].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(immediateIntervals.Values[Two].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(immediateIntervals.Completed).IsEqualTo(1); - List> clockIntervals = []; - var clockIntervalCompleted = 0; - _ = Signal.Sequence(Four, Three).TimeInterval(new VirtualClock(DateTimeOffset.UnixEpoch)) - .Subscribe(clockIntervals.Add, static ex => throw ex, () => clockIntervalCompleted++); - IEnumerable expectedClockIntervals = [Four, Five, Six]; - int[] clockIntervalValues = [clockIntervals[0].Value, clockIntervals[1].Value, clockIntervals[Two].Value]; - await Assert.That(clockIntervalValues.SequenceEqual(expectedClockIntervals)).IsTrue(); - await Assert.That(clockIntervals[0].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(clockIntervals[1].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(clockIntervals[Two].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(clockIntervalCompleted).IsEqualTo(1); - List> immediateIntervalActions = []; - var immediateIntervalCompleted = 0; - var immediateIntervalSignal = - (IInlineSignal>)Signal.Sequence(Two, Two).TimeInterval(Sequencer.Immediate); - immediateIntervalSignal - .Subscribe(immediateIntervalActions.Add, static ex => throw ex, () => immediateIntervalCompleted++) - .Dispose(); - IEnumerable expectedImmediateIntervalActions = [Two, Three]; - int[] immediateIntervalActionValues = [immediateIntervalActions[0].Value, immediateIntervalActions[1].Value]; - await Assert.That(immediateIntervalActionValues.SequenceEqual(expectedImmediateIntervalActions)).IsTrue(); - await Assert.That(immediateIntervalCompleted).IsEqualTo(1); - RecordingWitness> clockIntervalObserver = new(); - var clockIntervalSignal = - (IInlineSignal>)Signal.Sequence(Two, Three) - .TimeInterval(new VirtualClock(DateTimeOffset.UnixEpoch)); - clockIntervalSignal.Subscribe(clockIntervalObserver).Dispose(); - IEnumerable expectedClockIntervalObserver = [Two, Three, Four]; - int[] clockIntervalObserverValues = - [ - clockIntervalObserver.Values[0].Value, clockIntervalObserver.Values[1].Value, - clockIntervalObserver.Values[Two].Value - ]; - await Assert.That(clockIntervalObserverValues.SequenceEqual(expectedClockIntervalObserver)).IsTrue(); - await Assert.That(clockIntervalObserver.Values[0].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(clockIntervalObserver.Values[1].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(clockIntervalObserver.Values[Two].Interval).IsEqualTo(TimeSpan.Zero); - await Assert.That(clockIntervalObserver.Completed).IsEqualTo(1); - _ = Assert.Throws(() => - immediateIntervalSignal.Subscribe((IObserver>)null!)); - _ = Assert.Throws(() => - immediateIntervalSignal.Subscribe((Action>)null!, static _ => { }, static () => { })); - } - - /// Verifies delay-start signal branches, the sequencer work item, and queue guard clauses. - /// A task representing the asynchronous operation. - private static async Task VerifyDelayStartAndWorkItemBranches() - { - RecordingWitness shiftedObserver = new(); - Signal.Sequence(One, Two).DelayStart(TimeSpan.Zero, Sequencer.Immediate).Subscribe(shiftedObserver).Dispose(); - int[] expectedShiftedObserver = [One, Two]; - await Assert.That(shiftedObserver.Values.SequenceEqual(expectedShiftedObserver)).IsTrue(); - await Assert.That(shiftedObserver.Completed).IsEqualTo(1); - List shiftedActions = []; - var shiftedActionCompleted = 0; - _ = Signal.Sequence(Three, Two).DelayStart(TimeSpan.Zero, Sequencer.Immediate) - .Subscribe(shiftedActions.Add, static ex => throw ex, () => shiftedActionCompleted++); - int[] expectedShiftedActions = [Three, Four]; - await Assert.That(shiftedActions.SequenceEqual(expectedShiftedActions)).IsTrue(); - await Assert.That(shiftedActionCompleted).IsEqualTo(1); - var currentThreadShift = - (IRequireCurrentThread)Signal.Sequence(One, One).DelayStart(TimeSpan.Zero, Sequencer.CurrentThread); - await Assert.That(currentThreadShift.IsRequiredSubscribeOnCurrentThread()).IsTrue(); - var inlineShift = (IInlineSignal)Signal.Sequence(One, One).DelayStart(TimeSpan.Zero, Sequencer.Immediate); - _ = Assert.Throws(static () => Signal.Sequence(One, One) - .DelayStart(TimeSpan.Zero, Sequencer.Immediate) - .Subscribe((IObserver)null!)); - _ = Assert.Throws(() => inlineShift.Subscribe((Action)null!, static _ => { }, static () => { })); - _ = Assert.Throws(() => inlineShift.Subscribe(static _ => { }, static _ => { }, null!)); - List helperValues = []; - SequencerWorkItem helper = new(Sequencer.Immediate, One, (_, state) => - { - helperValues.Add(state); - return new ActionDisposable(static () => { }); - }); - helper.Invoke(); - helper.Dispose(); - helper.Invoke(); - int[] expectedHelperValues = [One]; - await Assert.That(helperValues.SequenceEqual(expectedHelperValues)).IsTrue(); - await VerifySequencerWorkItemDisposalBranches(); - var unusedScheduled = - ScheduledItem.Create(Sequencer.Immediate, "unused", static (_, _) => EmptyDisposable.Instance, One); - await Assert.That(new SequencerQueue().Remove(unusedScheduled)).IsFalse(); - _ = Assert.Throws(CreatePriorityQueueWithInvalidCapacity); - PriorityQueue shrink = new(ThirtyTwo); - for (var i = 0; i < ThirtyTwo; i++) - { - shrink.Enqueue(i); - } - - for (var i = 0; i < TwentySix; i++) - { - await Assert.That(shrink.Dequeue()).IsEqualTo(i); - } - } - - /// Verifies the sequencer work item disposes the action's disposable across invoke and dispose orderings. - /// A task representing the asynchronous verification. - private static async Task VerifySequencerWorkItemDisposalBranches() - { - // Invoke then dispose: the published disposable is released by Dispose exactly once, - // and a redundant second Dispose is a no-op. - var invokeThenDisposeReleased = 0; - SequencerWorkItem invokeThenDispose = new(Sequencer.Immediate, One, (_, _) => - new ActionDisposable(() => Interlocked.Increment(ref invokeThenDisposeReleased))); - invokeThenDispose.Invoke(); - invokeThenDispose.Dispose(); - invokeThenDispose.Dispose(); - await Assert.That(invokeThenDisposeReleased).IsEqualTo(1); - - // A null action result is coalesced to an empty disposable and never throws. - var nullActionRan = false; - SequencerWorkItem nullAction = new(Sequencer.Immediate, One, (_, _) => - { - nullActionRan = true; - return null!; - }); - nullAction.Invoke(); - nullAction.Dispose(); - await Assert.That(nullActionRan).IsTrue(); - - await VerifySequencerWorkItemPublishBranches(); - await VerifySequencerWorkItemDisposeRaceInvariant(); - } - - /// Verifies both compare-exchange outcomes of SequencerWorkItem.Publish. - /// A task representing the asynchronous verification. - private static async Task VerifySequencerWorkItemPublishBranches() - { - // Publish wins the empty slot: the disposable is stored and left alive for Dispose. - var stored = 0; - ActionDisposable storedDisposable = new(() => Interlocked.Increment(ref stored)); - IDisposable? winSlot = null; - SequencerWorkItemDisposal.Publish(ref winSlot, storedDisposable); - await Assert.That(ReferenceEquals(winSlot, storedDisposable)).IsTrue(); - await Assert.That(stored).IsEqualTo(0); - - // Publish loses to disposal (slot already claimed): the disposable is released immediately. - var loserDisposed = 0; - ActionDisposable loser = new(() => Interlocked.Increment(ref loserDisposed)); - IDisposable? loseSlot = EmptyDisposable.Instance; - SequencerWorkItemDisposal.Publish(ref loseSlot, loser); - await Assert.That(loserDisposed).IsEqualTo(1); - await Assert.That(ReferenceEquals(loseSlot, EmptyDisposable.Instance)).IsTrue(); - } - - /// Verifies the action's disposable is released exactly once when invoke and dispose race. - /// A task representing the asynchronous verification. - private static async Task VerifySequencerWorkItemDisposeRaceInvariant() - { - for (var iteration = 0; iteration < RaceIterations; iteration++) - { - var created = 0; - var disposed = 0; - SequencerWorkItem item = new(Sequencer.Immediate, One, (_, _) => - { - _ = Interlocked.Increment(ref created); - return new ActionDisposable(() => Interlocked.Increment(ref disposed)); - }); - - using Barrier barrier = new(RacingThreadCount); - var invoke = Task.Run(() => - { - barrier.SignalAndWait(); - item.Invoke(); - }); - var dispose = Task.Run(() => - { - barrier.SignalAndWait(); - item.Dispose(); - }); - await Task.WhenAll(invoke, dispose); - - // Whenever the action produced a disposable it is released once; otherwise nothing leaks. - await Assert.That(disposed).IsEqualTo(created); - await Assert.That(created <= 1).IsTrue(); - } - } - - /// Verifies the thread pool absolute scheduling and scheduled work item disposal branches. - /// A task representing the asynchronous verification. - private static async Task VerifyThreadPoolWorkItemBranchesAsync() - { - TaskCompletionSource absoluteRan = new(TaskCreationOptions.RunContinuationsAsynchronously); - var absolute = ThreadPoolSequencer.Instance.Schedule(Five, FixedTimestamp, (_, state) => - { - if (!absoluteRan.TrySetResult(state)) - { - throw new InvalidOperationException("Thread pool completion was already set."); - } - - return EmptyDisposable.Instance; - }); - await Assert.That(await absoluteRan.Task.WaitAsync(TimeSpan.FromSeconds(Five)).ConfigureAwait(false)) - .IsEqualTo(Five); - absolute.Dispose(); - absolute.Dispose(); - var delayedDisposed = CreateThreadPoolWorkItem(One, static (_, _) => EmptyDisposable.Instance); - delayedDisposed.Dispose(); - QueueThreadPoolWorkItem(delayedDisposed, TimeSpan.FromMilliseconds(Ten)); - var skipped = false; - var skippedItem = CreateThreadPoolWorkItem(Two, (_, _) => - { - skipped = true; - return EmptyDisposable.Instance; - }); - skippedItem.Dispose(); - InvokeThreadPoolWorkItem(skippedItem); - await Assert.That(skipped).IsFalse(); - var disposedReturned = 0; - object?[] holder = [null]; - var selfDisposing = CreateThreadPoolWorkItem(holder, (_, state) => - { - ((IDisposable)state[0]!).Dispose(); - return new ActionDisposable(() => disposedReturned++); - }); - holder[0] = selfDisposing; - InvokeThreadPoolWorkItem(selfDisposing); - await Assert.That(disposedReturned).IsEqualTo(1); - } - - /// Creates a thread pool scheduled work item for the given state and action. - /// The type of the work item state. - /// The state passed to the scheduled action. - /// The action invoked when the work item runs. - /// A new scheduled work item. - private static ThreadPoolSequencer.ScheduledWorkItem CreateThreadPoolWorkItem( - TState state, - Func action) => new(ThreadPoolSequencer.Instance, state, action); - - /// Creates a priority queue with an invalid capacity. - private static void CreatePriorityQueueWithInvalidCapacity() - { - PriorityQueue invalid = new(-1); - GC.KeepAlive(invalid); - } - /// Executes the supplied thread pool scheduled work item. /// The type of the work item state. /// The work item to execute. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs index ed4269cf..a7d3f97d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs @@ -7,15 +7,10 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the operator coordinators hold the Rx grammar even when a source breaks it: a notification that -/// arrives after the sequence has terminated, or after the subscription was disposed, is dropped rather than -/// forwarded. Sources here are scripted so that disposing the subscription does not unhook them, which is what -/// lets the test deliver the illegal notifications the guards exist for. -/// +/// Tests that operator coordinators drop notifications after termination or disposal. public partial class SignalOperatorMixinsTests { - /// The quiet period used by the timer-driven guard tests. + /// The virtual quiet period used by guard tests. private static readonly TimeSpan GuardPeriod = TimeSpan.FromMilliseconds(50); /// Verifies Pair drops values that arrive after the paired sequence has completed. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs new file mode 100644 index 00000000..be543818 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs @@ -0,0 +1,281 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Core; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests scheduled delivery and virtual-time operator behavior. +public partial class SignalOperatorMixinsTests +{ + /// Verifies the timestamp operator immediate and clock-backed branches. + /// A task representing the asynchronous operation. + private static async Task VerifyTimestampBranches() + { + RecordingWitness> immediateMoments = new(); + Signal.Sequence(One, Three).Timestamp(Sequencer.Immediate).Subscribe(immediateMoments).Dispose(); + IEnumerable expectedImmediateMoments = [One, Two, Three]; + int[] immediateMomentValues = + [immediateMoments.Values[0].Value, immediateMoments.Values[1].Value, immediateMoments.Values[Two].Value]; + await Assert.That(immediateMomentValues.SequenceEqual(expectedImmediateMoments)).IsTrue(); + await Assert.That(immediateMoments.Completed).IsEqualTo(1); + List> clockMoments = []; + var clockMomentCompleted = 0; + _ = Signal.Sequence(Four, Two).Timestamp(new VirtualClock(DateTimeOffset.UnixEpoch)) + .Subscribe(clockMoments.Add, static ex => throw ex, () => clockMomentCompleted++); + IEnumerable expectedClockMoments = [Four, Five]; + int[] clockMomentValues = [clockMoments[0].Value, clockMoments[1].Value]; + await Assert.That(clockMomentValues.SequenceEqual(expectedClockMoments)).IsTrue(); + await Assert.That(clockMomentCompleted).IsEqualTo(1); + List> immediateMomentActions = []; + var immediateMomentCompleted = 0; + var immediateTimestampSignal = + (IInlineSignal>)Signal.Sequence(Two, Two).Timestamp(Sequencer.Immediate); + immediateTimestampSignal.Subscribe(immediateMomentActions.Add, static ex => throw ex, () => immediateMomentCompleted++) + .Dispose(); + IEnumerable expectedImmediateMomentActions = [Two, Three]; + int[] immediateMomentActionValues = [immediateMomentActions[0].Value, immediateMomentActions[1].Value]; + await Assert.That(immediateMomentActionValues.SequenceEqual(expectedImmediateMomentActions)).IsTrue(); + await Assert.That(immediateMomentCompleted).IsEqualTo(1); + RecordingWitness> clockMomentObserver = new(); + var clockTimestampSignal = + (IInlineSignal>)Signal.Sequence(Two, Two).Timestamp(new VirtualClock(DateTimeOffset.UnixEpoch)); + clockTimestampSignal.Subscribe(clockMomentObserver).Dispose(); + IEnumerable expectedClockMomentObserver = [Two, Three]; + int[] clockMomentObserverValues = [clockMomentObserver.Values[0].Value, clockMomentObserver.Values[1].Value]; + await Assert.That(clockMomentObserverValues.SequenceEqual(expectedClockMomentObserver)).IsTrue(); + await Assert.That(clockMomentObserver.Completed).IsEqualTo(1); + _ = Assert.Throws(() => + immediateTimestampSignal.Subscribe((IObserver>)null!)); + _ = Assert.Throws(() => + immediateTimestampSignal.Subscribe((Action>)null!, static _ => { }, static () => { })); + } + + /// Verifies the time-interval operator immediate and clock-backed branches. + /// A task representing the asynchronous operation. + private static async Task VerifyTimeIntervalBranches() + { + RecordingWitness> immediateIntervals = new(); + Signal.Sequence(One, Three).TimeInterval(Sequencer.Immediate).Subscribe(immediateIntervals).Dispose(); + IEnumerable expectedImmediateIntervals = [One, Two, Three]; + int[] immediateIntervalValues = + [ + immediateIntervals.Values[0].Value, immediateIntervals.Values[1].Value, immediateIntervals.Values[Two].Value + ]; + await Assert.That(immediateIntervalValues.SequenceEqual(expectedImmediateIntervals)).IsTrue(); + await Assert.That(immediateIntervals.Values[0].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(immediateIntervals.Values[1].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(immediateIntervals.Values[Two].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(immediateIntervals.Completed).IsEqualTo(1); + List> clockIntervals = []; + var clockIntervalCompleted = 0; + _ = Signal.Sequence(Four, Three).TimeInterval(new VirtualClock(DateTimeOffset.UnixEpoch)) + .Subscribe(clockIntervals.Add, static ex => throw ex, () => clockIntervalCompleted++); + IEnumerable expectedClockIntervals = [Four, Five, Six]; + int[] clockIntervalValues = [clockIntervals[0].Value, clockIntervals[1].Value, clockIntervals[Two].Value]; + await Assert.That(clockIntervalValues.SequenceEqual(expectedClockIntervals)).IsTrue(); + await Assert.That(clockIntervals[0].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(clockIntervals[1].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(clockIntervals[Two].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(clockIntervalCompleted).IsEqualTo(1); + List> immediateIntervalActions = []; + var immediateIntervalCompleted = 0; + var immediateIntervalSignal = + (IInlineSignal>)Signal.Sequence(Two, Two).TimeInterval(Sequencer.Immediate); + immediateIntervalSignal + .Subscribe(immediateIntervalActions.Add, static ex => throw ex, () => immediateIntervalCompleted++) + .Dispose(); + IEnumerable expectedImmediateIntervalActions = [Two, Three]; + int[] immediateIntervalActionValues = [immediateIntervalActions[0].Value, immediateIntervalActions[1].Value]; + await Assert.That(immediateIntervalActionValues.SequenceEqual(expectedImmediateIntervalActions)).IsTrue(); + await Assert.That(immediateIntervalCompleted).IsEqualTo(1); + RecordingWitness> clockIntervalObserver = new(); + var clockIntervalSignal = + (IInlineSignal>)Signal.Sequence(Two, Three) + .TimeInterval(new VirtualClock(DateTimeOffset.UnixEpoch)); + clockIntervalSignal.Subscribe(clockIntervalObserver).Dispose(); + IEnumerable expectedClockIntervalObserver = [Two, Three, Four]; + int[] clockIntervalObserverValues = + [ + clockIntervalObserver.Values[0].Value, clockIntervalObserver.Values[1].Value, + clockIntervalObserver.Values[Two].Value + ]; + await Assert.That(clockIntervalObserverValues.SequenceEqual(expectedClockIntervalObserver)).IsTrue(); + await Assert.That(clockIntervalObserver.Values[0].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(clockIntervalObserver.Values[1].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(clockIntervalObserver.Values[Two].Interval).IsEqualTo(TimeSpan.Zero); + await Assert.That(clockIntervalObserver.Completed).IsEqualTo(1); + _ = Assert.Throws(() => + immediateIntervalSignal.Subscribe((IObserver>)null!)); + _ = Assert.Throws(() => + immediateIntervalSignal.Subscribe((Action>)null!, static _ => { }, static () => { })); + } + + /// Verifies delay-start signal branches, the sequencer work item, and queue guard clauses. + /// A task representing the asynchronous operation. + private static async Task VerifyDelayStartAndWorkItemBranches() + { + RecordingWitness shiftedObserver = new(); + Signal.Sequence(One, Two).DelayStart(TimeSpan.Zero, Sequencer.Immediate).Subscribe(shiftedObserver).Dispose(); + int[] expectedShiftedObserver = [One, Two]; + await Assert.That(shiftedObserver.Values.SequenceEqual(expectedShiftedObserver)).IsTrue(); + await Assert.That(shiftedObserver.Completed).IsEqualTo(1); + List shiftedActions = []; + var shiftedActionCompleted = 0; + _ = Signal.Sequence(Three, Two).DelayStart(TimeSpan.Zero, Sequencer.Immediate) + .Subscribe(shiftedActions.Add, static ex => throw ex, () => shiftedActionCompleted++); + int[] expectedShiftedActions = [Three, Four]; + await Assert.That(shiftedActions.SequenceEqual(expectedShiftedActions)).IsTrue(); + await Assert.That(shiftedActionCompleted).IsEqualTo(1); + var currentThreadShift = + (IRequireCurrentThread)Signal.Sequence(One, One).DelayStart(TimeSpan.Zero, Sequencer.CurrentThread); + await Assert.That(currentThreadShift.IsRequiredSubscribeOnCurrentThread()).IsTrue(); + var inlineShift = (IInlineSignal)Signal.Sequence(One, One).DelayStart(TimeSpan.Zero, Sequencer.Immediate); + _ = Assert.Throws(static () => Signal.Sequence(One, One) + .DelayStart(TimeSpan.Zero, Sequencer.Immediate) + .Subscribe((IObserver)null!)); + _ = Assert.Throws(() => inlineShift.Subscribe((Action)null!, static _ => { }, static () => { })); + _ = Assert.Throws(() => inlineShift.Subscribe(static _ => { }, static _ => { }, null!)); + List helperValues = []; + SequencerWorkItem helper = new(Sequencer.Immediate, One, (_, state) => + { + helperValues.Add(state); + return new ActionDisposable(static () => { }); + }); + helper.Invoke(); + helper.Dispose(); + helper.Invoke(); + int[] expectedHelperValues = [One]; + await Assert.That(helperValues.SequenceEqual(expectedHelperValues)).IsTrue(); + await VerifySequencerWorkItemDisposalBranches(); + var unusedScheduled = + ScheduledItem.Create(Sequencer.Immediate, "unused", static (_, _) => EmptyDisposable.Instance, One); + await Assert.That(new SequencerQueue().Remove(unusedScheduled)).IsFalse(); + _ = Assert.Throws(CreatePriorityQueueWithInvalidCapacity); + PriorityQueue shrink = new(ThirtyTwo); + for (var i = 0; i < ThirtyTwo; i++) + { + shrink.Enqueue(i); + } + + for (var i = 0; i < TwentySix; i++) + { + await Assert.That(shrink.Dequeue()).IsEqualTo(i); + } + } + + /// Verifies the sequencer work item disposes the action's disposable across invoke and dispose orderings. + /// A task representing the asynchronous verification. + private static async Task VerifySequencerWorkItemDisposalBranches() + { + // Invoke then dispose: the published disposable is released by Dispose exactly once, + // and a redundant second Dispose is a no-op. + var invokeThenDisposeReleased = 0; + SequencerWorkItem invokeThenDispose = new(Sequencer.Immediate, One, (_, _) => + new ActionDisposable(() => Interlocked.Increment(ref invokeThenDisposeReleased))); + invokeThenDispose.Invoke(); + invokeThenDispose.Dispose(); + invokeThenDispose.Dispose(); + await Assert.That(invokeThenDisposeReleased).IsEqualTo(1); + + // A null action result is coalesced to an empty disposable and never throws. + var nullActionRan = false; + SequencerWorkItem nullAction = new(Sequencer.Immediate, One, (_, _) => + { + nullActionRan = true; + return null!; + }); + nullAction.Invoke(); + nullAction.Dispose(); + await Assert.That(nullActionRan).IsTrue(); + + await VerifySequencerWorkItemPublishBranches(); + await VerifySequencerWorkItemDisposeRaceInvariant(); + } + + /// Verifies both compare-exchange outcomes of SequencerWorkItem.Publish. + /// A task representing the asynchronous verification. + private static async Task VerifySequencerWorkItemPublishBranches() + { + // Publish wins the empty slot: the disposable is stored and left alive for Dispose. + var stored = 0; + ActionDisposable storedDisposable = new(() => Interlocked.Increment(ref stored)); + IDisposable? winSlot = null; + SequencerWorkItemDisposal.Publish(ref winSlot, storedDisposable); + await Assert.That(ReferenceEquals(winSlot, storedDisposable)).IsTrue(); + await Assert.That(stored).IsEqualTo(0); + + // Publish loses to disposal (slot already claimed): the disposable is released immediately. + var loserDisposed = 0; + ActionDisposable loser = new(() => Interlocked.Increment(ref loserDisposed)); + IDisposable? loseSlot = EmptyDisposable.Instance; + SequencerWorkItemDisposal.Publish(ref loseSlot, loser); + await Assert.That(loserDisposed).IsEqualTo(1); + await Assert.That(ReferenceEquals(loseSlot, EmptyDisposable.Instance)).IsTrue(); + } + + /// Verifies the action's disposable is released exactly once when invoke and dispose race. + /// A task representing the asynchronous verification. + private static async Task VerifySequencerWorkItemDisposeRaceInvariant() +{ + var disposed = 0; + SequencerWorkItem? item = null; + item = new(Sequencer.Immediate, One, (_, _) => + { + item!.Dispose(); + return new ActionDisposable(() => disposed++); + }); + item.Invoke(); + item.Dispose(); + await Assert.That(disposed).IsEqualTo(1); + } + + /// Verifies the thread pool absolute scheduling and scheduled work item disposal branches. + /// A task representing the asynchronous verification. + private static async Task VerifyThreadPoolWorkItemBranchesAsync() +{ + using ManualThreadPool pool = new(); + var absoluteValue = 0; + var absolute = pool.Sequencer.Schedule(Five, FixedTimestamp, (_, state) => + { + absoluteValue = state; + return EmptyDisposable.Instance; + }); + pool.RunReady(); + await Assert.That(absoluteValue).IsEqualTo(Five); + absolute.Dispose(); + absolute.Dispose(); + var skipped = false; + ThreadPoolSequencer.ScheduledWorkItem delayed = new(pool.Sequencer, One, (_, _) => + { + skipped = true; + return EmptyDisposable.Instance; + }); + delayed.Dispose(); + delayed.Queue(TimeSpan.FromTicks(Ten)); + pool.RunDue(long.MaxValue); + delayed.Execute(); + await Assert.That(skipped).IsFalse(); + var disposedReturned = 0; + ThreadPoolSequencer.ScheduledWorkItem? selfDisposing = null; + selfDisposing = new(pool.Sequencer, One, (_, _) => + { + selfDisposing!.Dispose(); + return new ActionDisposable(() => disposedReturned++); + }); + selfDisposing.Execute(); + await Assert.That(disposedReturned).IsEqualTo(1); + } + + /// Creates a priority queue with an invalid capacity. + private static void CreatePriorityQueueWithInvalidCapacity() + { + PriorityQueue invalid = new(-1); + GC.KeepAlive(invalid); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs index a7ac4e1e..680018c3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs @@ -574,16 +574,12 @@ public async Task TaskChainDirectSignalHandlesErrorsAndDisposal() await Assert.That(taskFailure.Completed).IsEqualTo(0); TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); - Signal> disposableSource = new(); RecordingWitness disposed = new(); - { - var disposable = disposableSource.Chain().Subscribe(disposed); - disposableSource.OnNext(pending.Task); - disposable.Dispose(); - pending.SetResult(Five); - } + TaskInstanceSubscription disposable = new(); + disposable.Dispose(); + pending.SetResult(Five); + await TaskInstanceSignal.ObserveTaskAsync(pending.Task, disposed, disposable); - await Task.Yield(); await Assert.That(disposed.Values.Count).IsEqualTo(0); await Assert.That(disposed.Errors.Count).IsEqualTo(0); await Assert.That(disposed.Completed).IsEqualTo(0); @@ -731,7 +727,6 @@ private static async Task AssertTaskSignalAliasesForwardResultsCancellationAndFa { List chained = []; _ = Signal.FromEnumerable([Task.FromResult(One), Task.FromResult(Two)]).Chain().Subscribe(chained.Add); - await Task.Yield(); await Assert.That(chained.SequenceEqual(ExpectedOneTwo)).IsTrue(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.PostTerminalGuards.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.PostTerminalGuards.cs index 8fa6b724..c33d83b1 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.PostTerminalGuards.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.PostTerminalGuards.cs @@ -4,12 +4,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the sampling and flat-map coordinators hold the Rx grammar when a source breaks it: a stale timer -/// tick, a notification after a terminal, and a notification after disposal are all dropped instead of -/// forwarded. Sources here are scripted so that disposing the subscription does not unhook them, which is what -/// lets the test deliver the illegal notifications the guards exist for. -/// +/// Tests that sampling and flat-map coordinators reject stale callbacks and post-terminal notifications. public partial class SignalOperatorParityMixinsTests { /// The sample period used by the probe guard tests. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.Aliases.cs similarity index 93% rename from src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs rename to src/tests/ReactiveUI.Primitives.Tests/SignalTests.Aliases.cs index a2322486..4b7a741c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.Aliases.cs @@ -9,24 +9,9 @@ namespace ReactiveUI.Primitives.Tests; -/// Focused coverage for Rx-style alias factories and parity shortcut branches. -public class SignalAliasCoverageTests +/// Tests Rx-style alias factories and empty-source shortcuts. +public partial class SignalTests { - /// Reusable value one. - private const int One = 1; - - /// Reusable value two. - private const int Two = 2; - - /// Reusable value three. - private const int Three = 3; - - /// Reusable value four. - private const int Four = 4; - - /// Reusable value five. - private const int Five = 5; - /// Verifies Rx factory aliases cover scheduled, empty, timeout, and switch range branches. /// A task representing the asynchronous operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs index 9509210a..c3a13602 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Primitives.Tests; /// Tests for the signal type. -public class SignalTests +public partial class SignalTests { /// The integer constant one. private const int One = 1; @@ -523,12 +523,7 @@ public async Task SubjectsCoverMultipleSubscriberChurnLateTerminalsAndDisposalBr _ = Assert.Throws(() => disposedSubject.OnNext(1)); } - /// - /// A signal disposed from inside a subscriber's value callback has torn its state down under the dispatch - /// that is still running. The remaining subscribers in that dispatch snapshot still see the value — they - /// were already promised it — but the caller is told the signal is gone, rather than the disposal being - /// swallowed. - /// + /// Disposal during dispatch preserves the captured observer delivery and reports disposal to the caller. /// A task representing the asynchronous operation. [Test] public async Task OnNextReportsDisposalWhenASubscriberDisposesTheSignalMidDispatch() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs index d979fc20..ce772ee7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs @@ -528,12 +528,7 @@ public async Task BufferSinkReleasesTheUpstreamWhenTheObserverThrowsOnACompleted await Assert.That(subscription.DisposeCount).IsEqualTo(1); } - /// - /// A releases its window buffer before handing the window to the observer, - /// so an observer that throws leaves the sink with no buffer to fill. The sink must therefore latch itself - /// terminal on that throw: a source that ignores the sink's disposal and keeps pushing has to be dropped - /// quietly, not indexed into the released buffer. - /// + /// A buffer witness stops accepting values when delivery of a completed window throws. /// A task representing the asynchronous operation. [Test] public async Task BufferSinkDropsValuesFromASourceThatKeepsPushingAfterTheObserverThrows() @@ -732,11 +727,7 @@ private static void Feed(IObserver sink, IEnumerable values) public SumAggregator Add(int value) => new(Result + value); } - /// - /// An observable whose subscription retains its observer and ignores disposal, letting a test keep pushing - /// notifications into a sink that has already torn itself down. A well-behaved source would stop; this one is - /// what a sink's terminal latch exists to defend against. - /// + /// Retains its observer after disposal and permits further notifications. /// The value type. private sealed class UnstoppableSource : IObservable { diff --git a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs index 0c3249c4..46fd8dd2 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs @@ -143,11 +143,7 @@ public async Task StatefulSignalsReportObserverValueErrorAndDisposalState() await Assert.That(state.IsDisposed).IsTrue(); } - /// - /// A state signal that has already completed never replays its value on subscription, so the projection has - /// no value to seed from its source. It must fall back to projecting the source's current value directly, - /// and then replay that value plus the completion to anyone who subscribes later. - /// + /// A projection created after source completion seeds from its current value and replays completion. /// A task representing the asynchronous operation. [Test] public async Task ReadOnlyStateProjectionSeedsItselfFromASourceThatHasAlreadyCompleted() @@ -178,8 +174,7 @@ public async Task ReadOnlyStateProjectionIgnoresSourceNotificationsAfterItsTermi Recorder observer = new(); _ = projection.Subscribe(observer); - // The projection is its own observer of the source, so driving it directly is what a source that - // keeps notifying after the terminal looks like from the projection's side. + // Deliver notifications from a source that ignores termination. projection.OnCompleted(); projection.OnCompleted(); projection.OnNext(UpdatedStateValue); diff --git a/src/tests/ReactiveUI.Primitives.Tests/GenerationSafetyTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.Generation.cs similarity index 64% rename from src/tests/ReactiveUI.Primitives.Tests/GenerationSafetyTests.cs rename to src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.Generation.cs index 17d70ea3..5cdfa309 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/GenerationSafetyTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.Generation.cs @@ -8,12 +8,8 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Tests that the switch and merge coordinators keep their generation bookkeeping correct when a source -/// behaves awkwardly: re-entering the outer sequence from a downstream handler, or signalling completion -/// more than once. Both shapes previously lost notifications or terminated early. -/// -public sealed class GenerationSafetyTests +/// Tests switch generation tracking during reentrant subscription. +public sealed partial class SwitchWitnessTests { /// The value the first inner emits synchronously while it is being subscribed. private const int FirstValue = 1; @@ -21,12 +17,7 @@ public sealed class GenerationSafetyTests /// The value the second inner emits after the re-entrant switch has settled. private const int SecondValue = 2; - /// - /// Subscribing an inner can push a value downstream synchronously, and a downstream handler may feed the - /// outer sequence again. The newer generation must survive that re-entrancy: installing the older - /// subscription afterwards would dispose the newer one and strand the sequence, because every surviving - /// notification is then filtered out by version. - /// + /// Reentrant replacement during subscription keeps the newest inner active. /// A task representing the asynchronous operation. [Test] public async Task SwitchWitnessKeepsTheNewestGenerationWhenTheOuterIsReenteredWhileSubscribing() @@ -41,7 +32,7 @@ public async Task SwitchWitnessKeepsTheNewestGenerationWhenTheOuterIsReenteredWh // Pushing `first` subscribes it, which emits synchronously, which re-enters the outer with `second`. outer.OnNext(first); - // `second` is the current generation, so it must still be live and still be heard. + // The replacement remains subscribed after the first subscription returns. second.Observer?.OnNext(SecondValue); using (Assert.Multiple()) @@ -51,36 +42,6 @@ public async Task SwitchWitnessKeepsTheNewestGenerationWhenTheOuterIsReenteredWh } } - /// - /// A source may signal completion more than once. A repeat must not decrement the merge's active count a - /// second time, because that count belongs to a sibling that is still running - the merge would otherwise - /// complete early and drop whatever the sibling had left. - /// - /// A task representing the asynchronous operation. - [Test] - public async Task MergeCoordinatorIgnoresARepeatedInnerCompletionWhileASiblingIsStillRunning() - { - CapturingObservable left = new(); - CapturingObservable right = new(); - RecordingObserver downstream = new(); - - using var subscription = new MergeCoordinator(downstream).Run([left, right]); - - left.Observer!.OnCompleted(); - left.Observer!.OnCompleted(); - - // `right` has not finished, so the merge must still be open. - await Assert.That(downstream.Completed).IsEqualTo(0); - - right.Observer!.OnCompleted(); - - using (Assert.Multiple()) - { - await Assert.That(downstream.Completed).IsEqualTo(1); - await Assert.That(right.Observer).IsNotNull(); - } - } - /// An observable that emits one value while it is being subscribed, and counts its disposals. /// The value type. /// The value emitted during subscription. @@ -115,21 +76,6 @@ public IDisposable Subscribe(IObserver observer) } } - /// An observable that captures its observer for manual notification. - /// The value type. - private sealed class CapturingObservable : IObservable - { - /// Gets the captured observer. - public IObserver? Observer { get; private set; } - - /// - public IDisposable Subscribe(IObserver observer) - { - Observer = observer; - return new Tracker(static () => { }); - } - } - /// Records values and terminal signals. /// The observed value type. private class RecordingObserver : IObserver diff --git a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs index 3bb94515..9f0576fc 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Tests; /// Tests for . -public sealed class SwitchWitnessTests +public sealed partial class SwitchWitnessTests { /// The integer constant one. private const int One = 1; @@ -18,12 +18,6 @@ public sealed class SwitchWitnessTests /// The integer constant two. private const int Two = 2; - /// Window used to confirm a gated operation stays blocked while a delivery is in flight. - private const int GateProbeMilliseconds = 200; - - /// Timeout used while waiting for background work in these tests. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Verifies direct switch witness completion is emitted once. /// A task representing the asynchronous operation. [Test] @@ -46,34 +40,42 @@ public async Task SwitchWitnessSuppressesDuplicateCompletionFromCurrentInner() /// A task representing the asynchronous operation. [Test] public async Task SwitchToDoesNotEnterObserverConcurrentlyWhenSwitchingInnerSources() - { +{ Signal> outer = new(); CapturingObservable first = new(); CapturingObservable second = new(); using GatedObserver observer = new(); using var subscription = outer.SwitchTo().Subscribe(observer); - + var coordinator = (LinqExtensions.SwitchCoordinator)subscription; outer.OnNext(first); + var firstDelivery = Task.Run(() => first.Observer!.OnNext(One)); + observer.FirstEntered.Wait(); + bool entered; + try + { +#if NET9_0_OR_GREATER + entered = coordinator.Gate.TryEnter(); + if (entered) + { + coordinator.Gate.Exit(); + } +#else + entered = Monitor.TryEnter(coordinator.Gate); + if (entered) + { + Monitor.Exit(coordinator.Gate); + } +#endif + } + finally + { + observer.ReleaseFirst.Set(); + } - // The first inner value enters the observer and blocks while the coordinator holds its gate. Run the - // blocking delivery on a dedicated thread rather than the thread pool: on a saturated runner a Task.Run - // body can sit unscheduled past the wait window, so FirstEntered would never be set in time even though - // the delivery is ready to run. An OS thread is not subject to pool starvation. - var firstDelivery = DeliveryThread.Start(() => first.Observer!.OnNext(One)); - await Assert.That(observer.FirstEntered.Wait(WaitTimeout)).IsTrue(); - - // Switching sources is gated too: OnSource cannot subscribe the new inner until the delivery releases. - var switchDelivery = DeliveryThread.Start(() => outer.OnNext(second)); - await Assert.That(SpinWait.SpinUntil(() => second.Observer is not null, GateProbeMilliseconds)).IsFalse(); - await Assert.That(observer.ConcurrentOnNext).IsFalse(); - - observer.ReleaseFirst.Set(); - await Assert.That(firstDelivery.ReturnedWithin(WaitTimeout)).IsTrue(); - await Assert.That(switchDelivery.ReturnedWithin(WaitTimeout)).IsTrue(); - - // Once the switch completes the new inner delivers serially behind the first value. + await firstDelivery; + await Assert.That(entered).IsFalse(); + outer.OnNext(second); second.Observer!.OnNext(Two); - await Assert.That(observer.ConcurrentOnNext).IsFalse(); await Assert.That(observer.Values).IsEqualTo(Two); } @@ -374,7 +376,7 @@ public void OnError(Exception error) /// public void OnNext(int value) - { +{ if (Interlocked.Increment(ref _inOnNext) != 1) { _ = Interlocked.Exchange(ref _concurrent, 1); @@ -384,14 +386,11 @@ public void OnNext(int value) if (index == 1) { FirstEntered.Set(); - if (!ReleaseFirst.Wait(WaitTimeout)) - { - throw new TimeoutException("Timed out waiting to release the blocked OnNext call."); - } + ReleaseFirst.Wait(); } _ = Interlocked.Decrement(ref _inOnNext); - } + } /// public void Dispose() @@ -400,70 +399,4 @@ public void Dispose() ReleaseFirst.Dispose(); } } - - /// Runs a blocking notification delivery on a background thread the test can join directly. - /// - /// The deliveries these tests drive block inside the observer while the switch coordinator holds its gate. - /// A body would sit on the thread pool, so on a runner whose pool is saturated - /// the delivery could be dequeued after the wait window had already closed — the gate signal it sets would be - /// seen as never arriving even though the delivery was ready to run. is an - /// OS-level wait no pool pressure can starve, so the test observes the delivery returning rather than the pool - /// getting round to running it. A body that genuinely never returns still trips the timeout. - /// - private sealed class DeliveryThread - { - /// The delivery body to run. - private readonly Action _body; - - /// The background thread the body runs on. - private readonly Thread _thread; - - /// The exception the body threw, if any. - private ExceptionDispatchInfo? _failure; - - /// Initializes a new instance of the class. - /// The delivery body to run. - private DeliveryThread(Action body) - { - _body = body; - _thread = new(Run) { IsBackground = true, }; - } - - /// Starts a delivery body on a background thread of its own. - /// The delivery body to run. - /// The running delivery thread. - internal static DeliveryThread Start(Action body) - { - DeliveryThread delivery = new(body); - delivery._thread.Start(); - return delivery; - } - - /// Waits for the delivery thread to return, rethrowing whatever the body threw. - /// How long the thread is given to return. - /// when the thread returned within . - internal bool ReturnedWithin(TimeSpan timeout) - { - if (!_thread.Join(timeout)) - { - return false; - } - - _failure?.Throw(); - return true; - } - - /// Runs the delivery body, capturing a failure so it can be rethrown with its original stack. - private void Run() - { - try - { - _body(); - } - catch (Exception error) - { - _failure = ExceptionDispatchInfo.Capture(error); - } - } - } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs index 7bfb912f..9dd8fc69 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs @@ -261,11 +261,7 @@ private static Signal[] CreateSources(int count) return sources; } - /// - /// Drives every source and asserts the combined sequence: nothing is emitted until the final source - /// produces its first value, the first result carries each source's value in its own selector position, - /// and a later value on the first source replaces only that source's contribution. - /// + /// Checks first-value synchronization, selector positions, and replacement of one source's latest value. /// The subjects feeding the combinator, in selector-parameter order. /// The results recorded from the combined sequence. /// A task representing the asynchronous operation. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SynchronizeTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SynchronizeWitnessTests.cs similarity index 67% rename from src/tests/ReactiveUI.Primitives.Tests/SynchronizeTests.cs rename to src/tests/ReactiveUI.Primitives.Tests/SynchronizeWitnessTests.cs index 52d45d6d..94748694 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SynchronizeTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SynchronizeWitnessTests.cs @@ -9,14 +9,8 @@ namespace ReactiveUI.Primitives.Tests; /// Tests for the gate and the Synchronize operator. -public class SynchronizeTests +public class SynchronizeWitnessTests { - /// The number of producer threads used by stress tests. - private const int Threads = 8; - - /// The number of values sent by each producer thread. - private const int PerThread = 500; - /// The literal two. private const int Second = 2; @@ -73,51 +67,50 @@ public void SynchronizeOnNullGateThrows() => /// A task that completes when the assertions have run. [Test] public async Task SharedGateSerializesAcrossTwoWitnesses() - { - ConcurrencyProbe probe = new(); +{ Lock gate = new(); - SynchronizeWitness first = new(probe, gate); - SynchronizeWitness second = new(probe, gate); - var tasks = new Task[Threads]; - for (var t = 0; t < Threads; t++) + List values = []; + var held = true; + var observer = new DelegateWitness( + value => { - var sink = t % Second == 0 ? first : second; - tasks[t] = Task.Run(() => - { - for (var i = 0; i < PerThread; i++) - { - sink.OnNext(i); - } - }); - } - - await Task.WhenAll(tasks); - await Assert.That(probe.OverlapDetected).IsFalse(); - await Assert.That(probe.Count).IsEqualTo(Threads * PerThread); + held &= IsHeld(gate); + values.Add(value); + }, + static _ => { }, + static () => { }); + using SynchronizeWitness first = new(observer, gate); + using SynchronizeWitness second = new(observer, gate); + first.OnNext(1); + second.OnNext(Second); + await Assert.That(held).IsTrue(); + await Assert.That(values.SequenceEqual([1, Second])).IsTrue(); } - /// Concurrent OnNext calls are serialized: the downstream is never entered re-entrantly and sees every value. + /// Every downstream notification runs while the witness owns its gate. /// A task that completes when the assertions have run. [Test] - public async Task SerializesConcurrentOnNextSoTheDownstreamNeverOverlaps() - { - ConcurrencyProbe probe = new(); - SynchronizeWitness sink = new(probe); - var tasks = new Task[Threads]; - for (var t = 0; t < Threads; t++) + public async Task HoldsTheGateAcrossEveryDownstreamNotification() +{ + var held = true; + List values = []; + SynchronizeWitness? sink = null; + var observer = new DelegateWitness( + value => { - tasks[t] = Task.Run(() => - { - for (var i = 0; i < PerThread; i++) - { - sink.OnNext(i); - } - }); - } - - await Task.WhenAll(tasks); - await Assert.That(probe.OverlapDetected).IsFalse(); - await Assert.That(probe.Count).IsEqualTo(Threads * PerThread); + held &= IsHeld(sink!.Gate); + values.Add(value); + }, + _ => held &= IsHeld(sink!.Gate), + () => held &= IsHeld(sink!.Gate)); + sink = new(observer); + sink.OnNext(1); + sink.OnNext(Second); + sink.OnError(new InvalidOperationException("failure")); + sink.OnCompleted(); + await Assert.That(held).IsTrue(); + await Assert.That(values.SequenceEqual([1, Second])).IsTrue(); + sink.Dispose(); } /// The object-gated sequence forwards every source value and its completion downstream. @@ -177,27 +170,24 @@ public async Task ObjectGatedSynchronizeDisposesTheUpstreamSubscriptionOnce() /// A task that completes when the assertions have run. [Test] public async Task ObjectGatedWitnessesSharingOneGateAreSerialized() - { - ConcurrencyProbe probe = new(); +{ var gate = new object(); - SynchronizeObjectWitness first = new(probe, gate); - SynchronizeObjectWitness second = new(probe, gate); - var tasks = new Task[Threads]; - for (var t = 0; t < Threads; t++) + List values = []; + var held = true; + var observer = new DelegateWitness( + value => { - var sink = t % Second == 0 ? first : second; - tasks[t] = Task.Run(() => - { - for (var i = 0; i < PerThread; i++) - { - sink.OnNext(i); - } - }); - } - - await Task.WhenAll(tasks); - await Assert.That(probe.OverlapDetected).IsFalse(); - await Assert.That(probe.Count).IsEqualTo(Threads * PerThread); + held &= Monitor.IsEntered(gate); + values.Add(value); + }, + static _ => { }, + static () => { }); + using SynchronizeObjectWitness first = new(observer, gate); + using SynchronizeObjectWitness second = new(observer, gate); + first.OnNext(1); + second.OnNext(Second); + await Assert.That(held).IsTrue(); + await Assert.That(values.SequenceEqual([1, Second])).IsTrue(); } #if NET9_0_OR_GREATER @@ -217,6 +207,18 @@ public async Task SynchronizeWithAnObjectGateForwardsTheSourceSequence() } #endif + /// Reports ownership of the platform synchronization gate. + /// The gate under test. + /// Whether the calling thread owns the gate. + private static bool IsHeld(Lock gate) + { +#if NET9_0_OR_GREATER + return gate.IsHeldByCurrentThread; +#else + return Monitor.IsEntered(gate); +#endif + } + /// An observer that records all values, errors, and completion counts. /// The type of the observed values. private sealed class Recorder : IObserver @@ -266,46 +268,4 @@ public void Dispose() { } } - - /// A downstream observer that flags any re-entrant (overlapping) notification and counts deliveries. - private sealed class ConcurrencyProbe : IObserver - { - /// The number of wait spin iterations used to widen the re-entrancy detection window. - private const int SpinIterations = 50; - - /// Non-zero while a notification is in flight, used to detect re-entrancy. - private int _inside; - - /// Gets the number of values delivered. - public int Count { get; private set; } - - /// Gets a value indicating whether two notifications were ever observed to overlap. - public bool OverlapDetected { get; private set; } - - /// - public void OnCompleted() - { - } - - /// - /// The forwarded error (ignored). - public void OnError(Exception error) - { - } - - /// - /// The forwarded value. - public void OnNext(int value) - { - if (Interlocked.Exchange(ref _inside, 1) != 0) - { - OverlapDetected = true; - } - - // Non-atomic on purpose: the gate must serialize callers for this to stay exact. - Count++; - Thread.SpinWait(SpinIterations); - _ = Interlocked.Exchange(ref _inside, 0); - } - } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskInstanceSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskInstanceSignalTests.cs new file mode 100644 index 00000000..e4ea83c7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskInstanceSignalTests.cs @@ -0,0 +1,85 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Verifies task-instance results and disposal. +public class TaskInstanceSignalTests +{ + /// The value emitted by a successful task. + private const int Value = 7; + + /// Pending task instances forward success, failure, or cancellation. + /// Zero for success, one for failure, or two for cancellation. + /// The asynchronous test. + [Test] + [Arguments(0)] + [Arguments(1)] + [Arguments(2)] + public async Task PendingTaskForwardsItsOutcome(int outcome) + { + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskNotificationObserver observer = new(); + using var subscription = Signal.FromTask(pending.Task).Subscribe(observer); + if (outcome == 0) + { + pending.SetResult(Value); + } + else if (outcome == 1) + { + pending.SetException(new InvalidOperationException()); + } + else + { + pending.SetCanceled(); + } + + await observer.Terminal.Task; + await Assert.That(observer.Values.Count).IsEqualTo(outcome == 0 ? 1 : 0); + await Assert.That(observer.Completions).IsEqualTo(outcome == 0 ? 1 : 0); + if (outcome == 0) + { + await Assert.That(observer.Values[0]).IsEqualTo(Value); + await Assert.That(observer.Error).IsNull(); + } + else if (outcome == 1) + { + await Assert.That(observer.Error).IsTypeOf(); + } + else + { + await Assert.That(observer.Error).IsTypeOf(); + } + } + + /// Disposed subscriptions suppress results and faults after observation completes. + /// Whether the task fails. + /// The asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DisposedSubscriptionSuppressesOutcome(bool fault) + { + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskNotificationObserver observer = new(); + TaskInstanceSubscription subscription = new(); + subscription.Dispose(); + if (fault) + { + pending.SetException(new InvalidOperationException()); + } + else + { + pending.SetResult(Value); + } + + await TaskInstanceSignal.ObserveTaskAsync(pending.Task, observer, subscription); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Error).IsNull(); + await Assert.That(observer.Completions).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs new file mode 100644 index 00000000..d94b3b26 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests task-pool sequencer dispatch and cancellation. +public class TaskPoolSequencerTests +{ + /// Scheduling state returns a cancellation handle. + /// A task representing the asynchronous test. + [Test] + public async Task TestCreate() + { + ManualTaskScheduler scheduler = new(); + TaskPoolSequencer sequencer = new(new(scheduler)); + using var disposable = sequencer.Schedule(0, static (_, _) => EmptyDisposable.Instance); + await Assert.That(disposable).IsNotNull(); + scheduler.RunPending(); + } + + /// The clock returns a UTC timestamp. + /// A task representing the asynchronous test. + [Test] + public async Task TaskPoolNowUsesUtc() => + await Assert.That(TaskPoolSequencer.Instance.Now.Offset).IsEqualTo(TimeSpan.Zero); + + /// Immediate work is queued through the supplied task factory. + /// A task representing the asynchronous test. + [Test] + public async Task TaskPoolScheduleAction() + { + ManualTaskScheduler scheduler = new(); + TaskPoolSequencer sequencer = new(new(scheduler)); + StrongBox ran = new(); + using var scheduled = sequencer.Schedule(ran, static state => state.Value = true); + await Assert.That(ran.Value).IsFalse(); + scheduler.RunPending(); + await Assert.That(ran.Value).IsTrue(); + } + + /// Work due immediately uses the task factory queue. + /// A task representing the asynchronous test. + [Test] + public async Task TaskPoolScheduleActionDueNow() + { + ManualTaskScheduler scheduler = new(); + TaskPoolSequencer sequencer = new(new(scheduler)); + StrongBox ran = new(); + using var scheduled = sequencer.Schedule(ran, TimeSpan.Zero, static state => state.Value = true); + await Assert.That(ran.Value).IsFalse(); + scheduler.RunPending(); + await Assert.That(ran.Value).IsTrue(); + } + + /// Delayed work reaches the task factory only after its delay callback runs. + /// A task representing the asynchronous test. + [Test] + public async Task TaskPoolScheduleActionDue() + { + ManualTaskScheduler scheduler = new(); + ManualSequencer delays = new(); + TaskPoolSequencer sequencer = new(new(scheduler), delays); + StrongBox ran = new(); + using var scheduled = sequencer.Schedule(ran, DateTimeOffset.MaxValue, static (_, state) => + { + state.Value = true; + return EmptyDisposable.Instance; + }); + scheduler.RunPending(); + await Assert.That(ran.Value).IsFalse(); + delays.RunPending(); + await Assert.That(ran.Value).IsFalse(); + scheduler.RunPending(); + await Assert.That(ran.Value).IsTrue(); + } + + /// Cancellation suppresses delayed dispatch. + /// A task representing the asynchronous test. + [Test] + public async Task TaskPoolScheduleActionCancel() + { + ManualTaskScheduler scheduler = new(); + ManualSequencer delays = new(); + TaskPoolSequencer sequencer = new(new(scheduler), delays); + StrongBox runs = new(); + var scheduled = sequencer.Schedule(runs, DateTimeOffset.MaxValue, static (_, state) => + { + state.Value++; + return EmptyDisposable.Instance; + }); + scheduled.Dispose(); + delays.RunPending(); + scheduler.RunPending(); + await Assert.That(runs.Value).IsEqualTo(0); + } + + /// Large delays retain their cancellation handle. + /// A task representing the asynchronous test. + [Test] + public async Task TaskPoolDelayLargerThanIntMaxValue() + { + ManualTaskScheduler scheduler = new(); + ManualSequencer delays = new(); + TaskPoolSequencer sequencer = new(new(scheduler), delays); + var dueTime = TimeSpan.FromMilliseconds((double)int.MaxValue + 1); + using var scheduled = sequencer.Schedule(dueTime, static () => { }); + await Assert.That(scheduled).IsNotNull(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs new file mode 100644 index 00000000..60ad111a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs @@ -0,0 +1,239 @@ +// 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.Concurrent; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Verifies synchronous task factory results and validation. +public partial class TaskSignalTests +{ + /// The value emitted by the simple from-task result test. + private const int EmittedValue = 2; + + /// The value produced by the successful pending task. + private const int SuccessValue = 7; + + /// Exception message used by user exception tests. + private const string BreakExecutionMessage = "break execution"; + + /// Covers from-task cancellation callback argument validation. + [Test] + public void FromTaskValidatesCancellationCallback() + { + var taskSignal = Signal.FromTask(static _ => Task.FromResult(1), Sequencer.Immediate); + try + { + _ = Assert.Throws(() => taskSignal.GetOperationCanceled(null!)); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// Covers from-task result emission and completion. + /// A task representing the asynchronous operation. + [Test] + public async Task FromTaskEmitsResult() + { + var taskSignal = Signal.FromTask(static _ => Task.FromResult(EmittedValue), Sequencer.Immediate); + try + { + List taskValues = []; + var taskCompleted = 0; + _ = taskSignal.Subscribe(taskValues.Add, static error => throw error, () => taskCompleted++); + await Assert.That(taskValues.SequenceEqual([EmittedValue])).IsTrue(); + await Assert.That(taskCompleted).IsEqualTo(1); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// A synchronously completed task emits its result and completes through the immediate path. + /// A representing the asynchronous unit test. + [Test] + public async Task ImmediateSynchronousSuccessEmitsResultAndCompletes() + { + ConcurrentQueue values = new(); + ConcurrentQueue errors = new(); + var completed = 0; + var taskSignal = Signal.FromTask(static _ => Task.FromResult(SuccessValue), Sequencer.Immediate); + try + { + _ = taskSignal.Subscribe( + values.Enqueue, + error => errors.Enqueue(error.GetType().Name), + () => Interlocked.Increment(ref completed)); + await Assert.That(values.SequenceEqual([SuccessValue])).IsTrue(); + await Assert.That(errors).IsEmpty(); + await Assert.That(Volatile.Read(ref completed)).IsEqualTo(1); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// A synchronously canceled task errors with a cancellation through the immediate path. + /// A representing the asynchronous unit test. + [Test] + public async Task ImmediateSynchronousCanceledTaskErrors() + { + ConcurrentQueue values = new(); + ConcurrentQueue errors = new(); + var taskSignal = Signal.FromTask(static _ => Task.FromCanceled(new(true)), Sequencer.Immediate); + try + { + _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); + await Assert.That(values).IsEmpty(); + await Assert.That(errors.SequenceEqual([nameof(OperationCanceledException)])).IsTrue(); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// A synchronously faulted task forwards the exception through the immediate path. + /// A representing the asynchronous unit test. + [Test] + public async Task ImmediateSynchronousFaultedTaskForwardsError() + { + ConcurrentQueue values = new(); + ConcurrentQueue errors = new(); + var taskSignal = + Signal.FromTask( + static _ => Task.FromException(new InvalidOperationException(BreakExecutionMessage)), + Sequencer.Immediate); + try + { + _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); + await Assert.That(values).IsEmpty(); + await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// A throwing task factory forwards the exception through the immediate path. + /// A representing the asynchronous unit test. + [Test] + public async Task ImmediateFactoryThrowForwardsError() + { + ConcurrentQueue values = new(); + ConcurrentQueue errors = new(); + var taskSignal = Signal.FromTask( + static _ => throw new InvalidOperationException(BreakExecutionMessage), + Sequencer.Immediate); + try + { + _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); + await Assert.That(values).IsEmpty(); + await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// A throwing task factory forwards the exception through the scheduled path. + /// A representing the asynchronous unit test. + [Test] + public async Task ScheduledFactoryThrowForwardsError() + { + ConcurrentQueue values = new(); + ConcurrentQueue errors = new(); + var taskSignal = Signal.FromTask(static _ => throw new InvalidOperationException(BreakExecutionMessage)); + try + { + _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); + await Assert.That(values).IsEmpty(); + await Assert.That(errors.SequenceEqual([nameof(InvalidOperationException)])).IsTrue(); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// A synchronously completed task emits its result through the scheduled synchronous fast path. + /// A representing the asynchronous unit test. + [Test] + public async Task ScheduledSynchronousSuccessEmitsResultAndCompletes() + { + ConcurrentQueue values = new(); + ConcurrentQueue errors = new(); + var completed = 0; + var taskSignal = Signal.FromTask(static _ => Task.FromResult(SuccessValue), Sequencer.CurrentThread); + try + { + _ = taskSignal.Subscribe( + values.Enqueue, + error => errors.Enqueue(error.GetType().Name), + () => Interlocked.Increment(ref completed)); + await Assert.That(values.SequenceEqual([SuccessValue])).IsTrue(); + await Assert.That(errors).IsEmpty(); + await Assert.That(completed).IsEqualTo(1); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// A synchronously canceled task errors through the scheduled synchronous fast path. + /// A representing the asynchronous unit test. + [Test] + public async Task ScheduledSynchronousCanceledTaskErrors() + { + ConcurrentQueue values = new(); + ConcurrentQueue errors = new(); + var taskSignal = Signal.FromTask(static _ => Task.FromCanceled(new(true)), Sequencer.CurrentThread); + try + { + _ = taskSignal.Subscribe(values.Enqueue, error => errors.Enqueue(error.GetType().Name), static () => { }); + await Assert.That(values).IsEmpty(); + await Assert.That(errors.SequenceEqual([nameof(OperationCanceledException)])).IsTrue(); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// The non-generic RxVoid factory honors the scheduler overload and emits a completion. + /// A representing the asynchronous unit test. + [Test] + public async Task RxVoidFactoryWithSchedulerEmitsCompletion() + { + var completed = 0; + var taskSignal = Signal.FromTask(static _ => Task.FromResult(RxVoid.Default), Sequencer.Immediate); + try + { + _ = taskSignal.Subscribe(static _ => { }, static error => throw error, () => Interlocked.Increment(ref completed)); + await Assert.That(Volatile.Read(ref completed)).IsEqualTo(1); + } + finally + { + (taskSignal as IDisposable)?.Dispose(); + } + } + + /// Subscribing to a disposed immediate signal throws. + [Test] + public void ImmediateSignalSubscribeAfterDisposeThrows() + { + var taskSignal = Signal.FromTask(static _ => Task.FromResult(SuccessValue), Sequencer.Immediate); + ((IDisposable)taskSignal).Dispose(); + _ = Assert.Throws(() => taskSignal.Subscribe(static _ => { })); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Lifecycle.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Lifecycle.cs new file mode 100644 index 00000000..beaf7618 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Lifecycle.cs @@ -0,0 +1,162 @@ +// 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.Concurrent; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Verifies factory completion and cancellation cleanup. +public partial class TaskSignalTests +{ + /// Disposal with a cancellation handler. + private const int DisposedEnding = 2; + + /// Cancellation requested through the source token. + private const int TokenEnding = 3; + + /// Disposal without a cancellation handler. + private const int UnhandledCancellationEnding = 4; + + /// Both factory overloads finish their body and subscription cleanup for each terminal path. + /// Whether to call the generic factory overload. + /// Zero for success, one for fault, two for disposal, three for token cancellation, or four for unhandled cancellation. + /// The asynchronous test. + [Test] + [Arguments(false, 0)] + [Arguments(false, 1)] + [Arguments(false, 2)] + [Arguments(false, 3)] + [Arguments(false, 4)] + [Arguments(true, 0)] + [Arguments(true, 1)] + [Arguments(true, 2)] + [Arguments(true, 3)] + [Arguments(true, 4)] + public async Task FactoryLifecycleCompletesCleanup(bool generic, int ending) + { + ConcurrentQueue events = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource cleanup = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task? operation = null; + var signal = generic + ? Signal.FromTask(cts => operation = ExecuteFactoryAsync(events, release, ending, cts.Token)) + : Signal.FromTask(cts => operation = ExecuteFactoryAsync(events, release, ending, cts.Token)); + using var signalLifetime = (IDisposable)signal; + var fixture = signal.Recover(error => + { + events.Enqueue("recovered"); + return Signal.Fail(error); + }).OnCleanup(() => + { + events.Enqueue("cleanup"); + _ = cleanup.TrySetResult(); + }); + var results = 0; + var subscription = fixture.Subscribe(_ => results++, static _ => { }); + await Assert.That(events).Contains("started"); + await Assert.That(operation is not null).IsTrue(); + await CompleteFactoryAsync(ending, signal, subscription, release, operation!); + await cleanup.Task; + subscription.Dispose(); + await AssertFactoryEventsAsync(events, ending, results); + } + + /// Triggers the selected terminal path and joins the factory body. + /// The terminal path. + /// The task signal. + /// The subscription to cancel. + /// The factory release signal. + /// The factory body task. + /// The asynchronous operation. + private static async Task CompleteFactoryAsync( + int ending, + ITaskSignal signal, + IDisposable subscription, + TaskCompletionSource release, + Task operation) + { + if (ending < DisposedEnding) + { + release.SetResult(); + } + else if (ending == TokenEnding) + { + await signal.CancellationTokenSource!.CancelAsync(); + } + else + { + subscription.Dispose(); + } + + if (ending == 1) + { + await Assert.That(async () => await operation).Throws(); + } + else if (ending == UnhandledCancellationEnding) + { + await Assert.That(async () => await operation).Throws(); + } + else + { + await operation; + } + } + + /// Checks cleanup and result delivery after the factory finishes. + /// The recorded events. + /// The terminal path. + /// The result count. + /// The asynchronous assertions. + private static async Task AssertFactoryEventsAsync(ConcurrentQueue events, int ending, int results) + { + await Assert.That(events.Count(static value => value == "cleanup")).IsEqualTo(1); + await Assert.That(events.Contains("finished")).IsEqualTo(ending < DisposedEnding); + await Assert.That(events.Contains("cancel-start")).IsEqualTo(ending is DisposedEnding or TokenEnding); + await Assert.That(events.Contains("cancel-end")).IsEqualTo(ending is DisposedEnding or TokenEnding); + await Assert.That(results).IsEqualTo(ending == 0 ? 1 : 0); + await Assert.That(events.Contains("recovered")).IsEqualTo(ending is 1 or TokenEnding); + } + + /// Runs until released or canceled, recording body cleanup. + /// The event destination. + /// The factory release signal. + /// The terminal path. + /// The cancellation token. + /// The factory result. + /// The selected terminal path is a factory failure. + private static async Task ExecuteFactoryAsync( + ConcurrentQueue events, + TaskCompletionSource release, + int ending, + CancellationToken token) + { + events.Enqueue("started"); + var wait = release.Task.WaitAsync(token); + if (ending == UnhandledCancellationEnding) + { + await wait; + } + else + { + await wait.HandleCancellation(() => + { + events.Enqueue("cancel-start"); + events.Enqueue("cancel-end"); + }); + } + + if (!token.IsCancellationRequested) + { + events.Enqueue("finished"); + } + + if (ending == 1) + { + throw new InvalidOperationException(BreakExecutionMessage); + } + + return RxVoid.Default; + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Observation.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Observation.cs new file mode 100644 index 00000000..a92d7c90 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.Observation.cs @@ -0,0 +1,194 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Verifies task notification and cancellation ordering. +public partial class TaskSignalTests +{ + /// A canceled result or token produces one cancellation error. + /// Whether cancellation is carried by the result. + /// The asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ObservationReportsCancellation(bool canceledResult) + { + using CancellationTokenSource source = new(); + if (!canceledResult) + { + await source.CancelAsync(); + } + + var result = Task.FromResult((SuccessValue, canceledResult)); + Signal.TaskStopGate gate = new(); + TaskNotificationObserver observer = new(); + await Signal.ObserveTask(result, observer, gate, source.Token); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completions).IsEqualTo(0); + await Assert.That(observer.Error).IsTypeOf(); + await Assert.That(gate.TryStop()).IsFalse(); + } + + /// Pending factories deliver one terminal result through either subscription path. + /// Whether notifications use the immediate sequencer. + /// Zero for success, one for failure, or two for cancellation. + /// The asynchronous test. + [Test] + [Arguments(true, 0)] + [Arguments(true, 1)] + [Arguments(true, 2)] + [Arguments(false, 0)] + [Arguments(false, 1)] + [Arguments(false, 2)] + public async Task PendingFactoryDeliversTerminalResult(bool immediate, int outcome) + { + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + var signal = Signal.FromTask(_ => pending.Task, immediate ? Sequencer.Immediate : Sequencer.CurrentThread); + TaskNotificationObserver observer = new(); + using var signalLifetime = (IDisposable)signal; + using var subscription = signal.Subscribe(observer); + CompletePending(pending, outcome); + await observer.Terminal.Task; + await Assert.That(observer.Values.Count).IsEqualTo(outcome == 0 ? 1 : 0); + await Assert.That(observer.Completions).IsEqualTo(outcome == 0 ? 1 : 0); + if (outcome == 0) + { + await Assert.That(observer.Values[0]).IsEqualTo(SuccessValue); + await Assert.That(observer.Error).IsNull(); + } + else if (outcome == 1) + { + await Assert.That(observer.Error).IsTypeOf(); + } + else + { + await Assert.That(observer.Error is OperationCanceledException).IsTrue(); + } + } + + /// A disposal claim suppresses either a value or a fault after observation finishes. + /// Whether the task fails. + /// The asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DisposedObservationSuppressesTerminalResult(bool fault) + { + TaskCompletionSource<(int Value, bool IsCanceled)> pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + Signal.TaskStopGate gate = new(); + TaskNotificationObserver observer = new(); + var observation = Signal.ObserveTask(pending.Task, observer, gate, CancellationToken.None); + await Assert.That(gate.TryStop()).IsTrue(); + if (fault) + { + pending.SetException(new InvalidOperationException(BreakExecutionMessage)); + } + else + { + pending.SetResult((SuccessValue, false)); + } + + await observation; + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Error).IsNull(); + await Assert.That(observer.Completions).IsEqualTo(0); + } + + /// Disposal tolerates a released token source and suppresses the subsequent result. + /// The asynchronous test. + [Test] + public async Task DisposalToleratesReleasedCancellationSource() + { + CancellationTokenSource source = new(); + Signal.TaskStopGate gate = new(); + TaskNotificationObserver observer = new(); + TaskCompletionSource<(int Value, bool IsCanceled)> pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + var token = source.Token; + var subscription = Signal.CancelOnDispose(gate, source); + source.Dispose(); + subscription.Dispose(); + pending.SetResult((SuccessValue, false)); + await Signal.ObserveTask(pending.Task, observer, gate, token); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Error).IsNull(); + await Assert.That(observer.Completions).IsEqualTo(0); + } + + /// Disposing a pending subscription cancels the token in either subscription path. + /// Whether the immediate path is selected. + /// The asynchronous test. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task SubscriptionDisposalCancelsPendingFactory(bool immediate) + { + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + var signal = Signal.FromTask(_ => pending.Task, immediate ? Sequencer.Immediate : Sequencer.CurrentThread); + using var lifetime = (IDisposable)signal; + var subscription = signal.Subscribe(static _ => { }); + subscription.Dispose(); + await Assert.That(signal.IsCancellationRequested).IsTrue(); + pending.SetResult(SuccessValue); + } + + /// Cancellation before a result reaches the observer produces one error. + /// The asynchronous test. + [Test] + public async Task ImmediateSignalCancellationPrecedesResult() + { + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + var signal = Signal.FromTask(_ => pending.Task, Sequencer.Immediate); + using var lifetime = (IDisposable)signal; + TaskNotificationObserver observer = new(); + using var subscription = signal.Subscribe(observer); + await Assert.That(ReferenceEquals(signal.Source, signal)).IsTrue(); + await signal.CancellationTokenSource!.CancelAsync(); + pending.SetResult(SuccessValue); + await observer.Terminal.Task; + await Assert.That(observer.Error).IsTypeOf(); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completions).IsEqualTo(0); + } + + /// Repeated disposal raises the cancellation callback once and preserves the disposed state. + /// The asynchronous test. + [Test] + public async Task ImmediateSignalDisposalRaisesCancellationOnce() + { + var signal = Signal.FromTask(static _ => Task.FromResult(SuccessValue), Sequencer.Immediate); + var cancellations = 0; + signal.GetOperationCanceled(Witness.Create(_ => cancellations++)); + await Assert.That(signal.IsDisposed).IsFalse(); + await Assert.That(signal.IsCancellationRequested).IsFalse(); + ((IDisposable)signal).Dispose(); + ((IDisposable)signal).Dispose(); + await Assert.That(signal.IsDisposed).IsTrue(); + await Assert.That(signal.IsCancellationRequested).IsTrue(); + await Assert.That(cancellations).IsEqualTo(1); + } + + /// Completes a pending factory with the selected outcome. + /// The controlled task source. + /// The terminal outcome. + private static void CompletePending(TaskCompletionSource pending, int outcome) + { + if (outcome == 0) + { + pending.SetResult(SuccessValue); + } + else if (outcome == 1) + { + pending.SetException(new InvalidOperationException(BreakExecutionMessage)); + } + else + { + pending.SetCanceled(); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs index 93dfe02a..fbf96a62 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies cancellation and disposal contracts. -public class TaskSignalTests +public partial class TaskSignalTests { /// Covers task-signal cancellation registration and disposal branches. /// A task representing the asynchronous operation. @@ -57,8 +57,7 @@ public async Task TaskSignalDisposeToleratesAnAlreadyDisposedTokenSource() CancellationTokenSource cts = new(); var taskSignal = TaskSignal.Create(static _ => Signal.Silent(), Sequencer.CurrentThread, cts); - // The task-completion path can release the token source before the outer subscription is disposed, so - // cancelling it during disposal throws ObjectDisposedException, which disposal has to swallow. + // Completion may release the token source before subscription disposal. cts.Dispose(); taskSignal.Dispose(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs index bd73cb81..a24afd69 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs @@ -14,23 +14,6 @@ public sealed class WasmSequencerTests /// Expected values produced by an immediate burst, used to verify FIFO order. private static readonly int[] ExpectedBurst = [1, 2, 3]; - /// Longest a test waits for scheduled work before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// How far in the future delayed work is scheduled. - private static readonly TimeSpan ScheduleDelay = TimeSpan.FromMilliseconds(50); - - /// How long a disposed sequencer is watched to prove it never ran the work it rejected or released. - private static readonly TimeSpan PostDisposeObservationWindow = TimeSpan.FromMilliseconds(200); - - /// - /// How long to wait for the marshal step to release a delayed item once it comes due. The release runs on the - /// shared timer's pool thread, so on a saturated runner it can fire long after the item is due; a real failure - /// to release the item never signals, so this window only has to outlast a slow runner and costs nothing when - /// the item is released promptly. - /// - private static readonly TimeSpan ReleaseTimeout = TimeSpan.FromSeconds(30); - /// Verifies the shared instance is a singleton. /// A representing the asynchronous operation. [Test] @@ -58,16 +41,18 @@ public async Task ClockAdvances() await Assert.That(sequencer.Timestamp).IsGreaterThanOrEqualTo(before); } - /// Verifies immediate work executes without the caller pumping anything. + /// Immediate work executes when its queued drain runs. /// A representing the asynchronous operation. [Test] public async Task ImmediateScheduleExecutes() { - TaskCompletionSource executed = new(TaskCreationOptions.RunContinuationsAsynchronously); - - WasmSequencer.Default.Schedule(new DelegateWorkItem(() => executed.TrySetResult(true))); - - await Assert.That(await executed.Task.WaitAsync(WaitTimeout)).IsTrue(); + Queue drains = new(); + using var sequencer = CreateSequencer(drains); + var executed = false; + sequencer.Schedule(new DelegateWorkItem(() => executed = true)); + await Assert.That(executed).IsFalse(); + drains.Dequeue()(); + await Assert.That(executed).IsTrue(); } /// Verifies a burst of immediate work executes in FIFO order. @@ -75,54 +60,49 @@ public async Task ImmediateScheduleExecutes() [Test] public async Task ImmediateBurstExecutesInOrder() { - TaskCompletionSource done = new(TaskCreationOptions.RunContinuationsAsynchronously); + Queue drains = new(); + using var sequencer = CreateSequencer(drains); List values = []; - foreach (var value in ExpectedBurst) { - var captured = value; - WasmSequencer.Default.Schedule(new DelegateWorkItem(() => - { - values.Add(captured); - if (values.Count != ExpectedBurst.Length) - { - return; - } - - _ = done.TrySetResult(true); - })); + sequencer.Schedule(new DelegateWorkItem(() => values.Add(value))); } - _ = await done.Task.WaitAsync(WaitTimeout); - await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default); + await Assert.That(drains.Count).IsEqualTo(1); + drains.Dequeue()(); + await Assert.That(values.SequenceEqual(ExpectedBurst)).IsTrue(); } - /// Verifies delayed work executes no earlier than its due timestamp. + /// Delayed work enters the drain only when the delay callback runs. /// A representing the asynchronous operation. [Test] public async Task DelayedScheduleExecutesAfterDue() { - var sequencer = WasmSequencer.Default; - TaskCompletionSource executed = new(TaskCreationOptions.RunContinuationsAsynchronously); - var start = sequencer.Timestamp; - var due = Sequencer.AddTimestamp(start, ScheduleDelay); - - sequencer.Schedule(new DelegateWorkItem(() => executed.TrySetResult(sequencer.Timestamp)), due); - - var executedAt = await executed.Task.WaitAsync(WaitTimeout); - await Assert.That(executedAt).IsGreaterThanOrEqualTo(start); + Queue drains = new(); + ManualSequencer delays = new(); + using var sequencer = CreateSequencer(drains, delays); + var executed = false; + sequencer.Schedule(new DelegateWorkItem(() => executed = true), long.MaxValue); + await Assert.That(drains.Count).IsEqualTo(0); + await Assert.That(executed).IsFalse(); + delays.RunPending(); + await Assert.That(drains.Count).IsEqualTo(1); + await Assert.That(executed).IsFalse(); + drains.Dequeue()(); + await Assert.That(executed).IsTrue(); } - /// Verifies a past-due timestamp executes promptly through the immediate path. + /// Past-due work uses the immediate queue. /// A representing the asynchronous operation. [Test] public async Task PastDueScheduleExecutes() { - TaskCompletionSource executed = new(TaskCreationOptions.RunContinuationsAsynchronously); - - WasmSequencer.Default.Schedule(new DelegateWorkItem(() => executed.TrySetResult(true)), long.MinValue); - - await Assert.That(await executed.Task.WaitAsync(WaitTimeout)).IsTrue(); + Queue drains = new(); + using var sequencer = CreateSequencer(drains); + var executed = false; + sequencer.Schedule(new DelegateWorkItem(() => executed = true), long.MinValue); + drains.Dequeue()(); + await Assert.That(executed).IsTrue(); } /// Verifies a cancelled work item never executes while later work still runs. @@ -130,16 +110,17 @@ public async Task PastDueScheduleExecutes() [Test] public async Task CancelledItemIsSkipped() { - TaskCompletionSource markerRan = new(TaskCreationOptions.RunContinuationsAsynchronously); + Queue drains = new(); + using var sequencer = CreateSequencer(drains); var cancelledRan = false; + var markerRan = false; CancellableWorkItem cancelled = new(() => cancelledRan = true); cancelled.Dispose(); - - WasmSequencer.Default.Schedule(cancelled); - WasmSequencer.Default.Schedule(new DelegateWorkItem(() => markerRan.TrySetResult(true))); - - _ = await markerRan.Task.WaitAsync(WaitTimeout); + sequencer.Schedule(cancelled); + sequencer.Schedule(new DelegateWorkItem(() => markerRan = true)); + drains.Dequeue()(); await Assert.That(cancelledRan).IsFalse(); + await Assert.That(markerRan).IsTrue(); } /// Verifies disposing a fresh sequencer releases its drain timer and is idempotent. @@ -154,127 +135,106 @@ public async Task DisposeReleasesDrainTimerAndIsIdempotent() await Assert.That(sequencer.Dispose).ThrowsNothing(); } - /// - /// Verifies a disposed sequencer rejects new work rather than queueing work it can never drain. Disposal releases - /// the drain timer, so an accepted item would sit in the ready queue forever behind a timer that can no longer be - /// armed. Both scheduling overloads fail fast instead, and neither item runs. - /// + /// A disposed sequencer rejects immediate and delayed work. /// A representing the asynchronous operation. [Test] public async Task ScheduleAfterDisposeThrowsObjectDisposedException() { - WasmSequencer sequencer = new(); + Queue drains = new(); + var sequencer = CreateSequencer(drains); var ran = 0; - DelegateWorkItem immediate = new(() => Interlocked.Increment(ref ran)); - DelegateWorkItem delayed = new(() => Interlocked.Increment(ref ran)); - + DelegateWorkItem immediate = new(() => ran++); + DelegateWorkItem delayed = new(() => ran++); sequencer.Dispose(); - await Assert.That(() => sequencer.Schedule(immediate)).ThrowsExactly(); - await Assert - .That(() => sequencer.Schedule(delayed, Sequencer.AddTimestamp(sequencer.Timestamp, ScheduleDelay))) - .ThrowsExactly(); - - await Task.Delay(PostDisposeObservationWindow); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + await Assert.That(() => sequencer.Schedule(delayed, long.MaxValue)).ThrowsExactly(); + await Assert.That(drains.Count).IsEqualTo(0); + await Assert.That(ran).IsEqualTo(0); } - /// - /// Verifies an enqueue that loses the race to disposal releases the item it just queued. The disposed check runs - /// before the item joins the ready queue, so a disposal landing in between would otherwise strand the item behind - /// a drain timer that can never fire again — the caller would hold a handle to work that neither runs nor cancels. - /// + /// The ready queue releases work received after disposal. /// A representing the asynchronous operation. [Test] - public async Task ScheduleReadyThatLosesTheRaceToDisposeReleasesTheItemItQueued() + public async Task ScheduleReadyAfterDisposeReleasesTheItem() { - WasmSequencer sequencer = new(); + Queue drains = new(); + var sequencer = CreateSequencer(drains); var ran = 0; - CancellableWorkItem item = new(() => Interlocked.Increment(ref ran)); - + CancellableWorkItem item = new(() => ran++); sequencer.Dispose(); - - // The enqueue that was already past the disposed check when the disposal drained the ready queue. sequencer.ScheduleReady(item); - await Assert.That(item.IsDisposed).IsTrue(); - - await Task.Delay(PostDisposeObservationWindow); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + await Assert.That(drains.Count).IsEqualTo(0); + await Assert.That(ran).IsEqualTo(0); } - /// - /// Verifies delayed work still parked on the shared timer when the sequencer is disposed is released rather than - /// marshalled back into a sequencer that is gone. The marshal step must neither run the item nor throw - /// on the timer's thread, where nothing could catch it. - /// + /// A delay callback releases work when the destination sequencer is disposed. /// A representing the asynchronous operation. [Test] public async Task DisposeReleasesDelayedWorkThatComesDueAfterwards() { - WasmSequencer sequencer = new(); + Queue drains = new(); + ManualSequencer delays = new(); + var sequencer = CreateSequencer(drains, delays); var ran = 0; - using ManualResetEventSlim released = new(); - CancellableWorkItem delayed = new(() => Interlocked.Increment(ref ran), released.Set); - - sequencer.Schedule(delayed, Sequencer.AddTimestamp(sequencer.Timestamp, ScheduleDelay)); + var released = 0; + CancellableWorkItem delayed = new(() => ran++, () => released++); + sequencer.Schedule(delayed, long.MaxValue); sequencer.Dispose(); - - // The marshal step disposes the item on the shared timer's pool thread once it comes due. Wait on the - // actual release signal rather than sleeping a fixed window: on a saturated runner the pool-driven marshal - // step can fire well after any fixed delay, so a fixed sleep would report the item as never released even - // though it was. An event wait is an OS-level wait no pool pressure can starve, and a genuine failure to - // release the item never signals, so the generous window still fails. - await Assert.That(released.Wait(ReleaseTimeout)).IsTrue(); + delays.RunPending(); + await Assert.That(released).IsEqualTo(1); await Assert.That(delayed.IsDisposed).IsTrue(); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + await Assert.That(drains.Count).IsEqualTo(0); + await Assert.That(ran).IsEqualTo(0); } - /// - /// Verifies delayed work cancelled before it comes due is dropped by the marshal step rather than pushed onto the - /// drain. The shared timer still fires, but the marshalled item observes the cancellation and returns without - /// scheduling anything. - /// + /// Canceled delayed work never reaches the ready queue. /// A representing the asynchronous operation. [Test] public async Task DelayedItemCancelledBeforeItIsDueIsSkippedByTheMarshalStep() { - WasmSequencer sequencer = new(); + Queue drains = new(); + ManualSequencer delays = new(); + using var sequencer = CreateSequencer(drains, delays); var ran = 0; - CancellableWorkItem delayed = new(() => Interlocked.Increment(ref ran)); - - sequencer.Schedule(delayed, Sequencer.AddTimestamp(sequencer.Timestamp, ScheduleDelay)); + CancellableWorkItem delayed = new(() => ran++); + sequencer.Schedule(delayed, long.MaxValue); delayed.Dispose(); - - // Outlast the due time: the marshal step must observe the cancellation and drop the item. - await Task.Delay(ScheduleDelay + PostDisposeObservationWindow); - + delays.RunPending(); await Assert.That(delayed.IsDisposed).IsTrue(); - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + await Assert.That(drains.Count).IsEqualTo(0); + await Assert.That(ran).IsEqualTo(0); } - /// - /// Verifies delayed work that carries no cancellation handle is simply dropped when the sequencer is disposed - /// before the item comes due. The marshal step cannot hand a non-disposable item back to a caller, so it releases - /// nothing and never runs it. - /// + /// Disposal suppresses delayed work without a cancellation handle. /// A representing the asynchronous operation. [Test] public async Task DisposeDropsDelayedNonDisposableWorkThatComesDueAfterwards() { - WasmSequencer sequencer = new(); + Queue drains = new(); + ManualSequencer delays = new(); + var sequencer = CreateSequencer(drains, delays); var ran = 0; - DelegateWorkItem delayed = new(() => Interlocked.Increment(ref ran)); - - sequencer.Schedule(delayed, Sequencer.AddTimestamp(sequencer.Timestamp, ScheduleDelay)); + sequencer.Schedule(new DelegateWorkItem(() => ran++), long.MaxValue); sequencer.Dispose(); - - // Outlast the due time: the marshal step sees the disposed owner and, with no handle to release, drops it. - await Task.Delay(ScheduleDelay + PostDisposeObservationWindow); - - await Assert.That(Volatile.Read(ref ran)).IsEqualTo(0); + delays.RunPending(); + await Assert.That(drains.Count).IsEqualTo(0); + await Assert.That(ran).IsEqualTo(0); } + /// Creates a sequencer whose event loop is driven explicitly. + /// The queued drain callbacks. + /// The delayed callback queue. + /// The isolated sequencer. + private static WasmSequencer CreateSequencer(Queue drains, ManualSequencer? delays = null) => + new( + drain => + { + drains.Enqueue(drain); + return true; + }, + (delays ?? new ManualSequencer()).Schedule); + /// Work item that invokes a delegate when executed. private sealed class DelegateWorkItem : IWorkItem { @@ -298,7 +258,7 @@ private sealed class CancellableWorkItem(Action action, Action? onDisposed = nul /// The action to run on execution. private readonly Action _action = action; - /// Invoked the instant the item is disposed, so a test can wait on the real release. + /// Reports disposal. private readonly Action? _onDisposed = onDisposed; /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.Helpers.cs b/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.Helpers.cs new file mode 100644 index 00000000..f6d9d29e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.Helpers.cs @@ -0,0 +1,155 @@ +// 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.Runtime.CompilerServices; +using ReactiveUI.Primitives.Advanced; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Tests; + +/// Verifies routing and safe-termination contracts. +public partial class WitnessTests +{ + /// Asserts each witness rejects the callback or observer it cannot work without. + private static void AssertWitnessConstructorsRejectMissingCallbacks() + { + _ = Assert.Throws(static () => + { + CallbackWitness invalid = new(null!, null, null); + GC.KeepAlive(invalid); + }); + _ = Assert.Throws(static () => + { + ForwardingWitness invalid = new(null!); + GC.KeepAlive(invalid); + }); + _ = Assert.Throws(static () => + { + StatefulWitness invalid = new(State, null!, null, null); + GC.KeepAlive(invalid); + }); + } + + /// Asserts a callback witness forwards each notification, and rethrows when no error callback was given. + /// A task representing the asynchronous operation. + private static async Task AssertCallbackWitnessForwardsEachNotification() + { + List callbackValues = []; + List callbackErrors = []; + List callbackCompletions = []; + CallbackWitness callback = new(callbackValues.Add, callbackErrors.Add, callbackCompletions.Add); + InvalidOperationException callbackError = new("callback"); + callback.OnNext(One); + callback.OnError(callbackError); + callback.OnCompleted(); + await Assert.That(callbackValues.SequenceEqual([One])).IsTrue(); + await Assert.That(callbackErrors[0]).IsSameReferenceAs(callbackError); + await Assert.That(callbackCompletions[0].IsSuccess).IsTrue(); + InvalidOperationException callbackFallback = new("callback fallback"); + _ = Assert.Throws(() => + new CallbackWitness(static _ => { }, null, null).OnError(callbackFallback)); + new CallbackWitness(static _ => { }, null, null).OnCompleted(); + } + + /// Asserts a forwarding witness passes every notification through to the observer it wraps. + /// A task representing the asynchronous operation. + private static async Task AssertForwardingWitnessForwardsEachNotification() + { + Recorder forwarded = new(); + ForwardingWitness forwarding = new(forwarded); + InvalidOperationException forwardingError = new("forwarding"); + forwarding.OnNext(Two); + forwarding.OnError(forwardingError); + forwarding.OnCompleted(); + await Assert.That(forwarded.Values.SequenceEqual([Two])).IsTrue(); + await Assert.That(forwarded.Errors[0]).IsSameReferenceAs(forwardingError); + await Assert.That(forwarded.Completed).IsEqualTo(1); + } + + /// Asserts a stateful witness hands its state to every callback, and rethrows without an error callback. + /// A task representing the asynchronous operation. + private static async Task AssertStatefulWitnessForwardsEachNotificationWithItsState() + { + List statefulValues = []; + List statefulErrors = []; + List statefulCompletions = []; + StatefulWitness stateful = new( + State, + (value, state) => statefulValues.Add($"{state}:{value}"), + (error, state) => statefulErrors.Add($"{state}:{error.Message}"), + (result, state) => statefulCompletions.Add($"{state}:{result.IsSuccess}")); + InvalidOperationException statefulError = new("stateful"); + stateful.OnNext(One); + stateful.OnError(statefulError); + stateful.OnCompleted(); + await Assert.That(statefulValues.SequenceEqual([$"{State}:{One}"])).IsTrue(); + await Assert.That(statefulErrors.SequenceEqual([$"{State}:{statefulError.Message}"])).IsTrue(); + await Assert.That(statefulCompletions.SequenceEqual([$"{State}:True"])).IsTrue(); + InvalidOperationException statefulFallback = new("stateful fallback"); + _ = Assert.Throws(() => + new StatefulWitness(State, static (_, _) => { }, null, null).OnError(statefulFallback)); + new StatefulWitness(State, static (_, _) => { }, null, null).OnCompleted(); + } + + /// Asserts a safe witness drops every notification that arrives after its first terminal one. + /// A task representing the asynchronous operation. + private static async Task AssertSafeWitnessIgnoresNotificationsAfterTheTerminal() + { + List safeValues = []; + List safeErrors = []; + var safeCompleted = 0; + var safe = Witness.Safe(Witness.Create(safeValues.Add, safeErrors.Add, () => safeCompleted++)); + safe.OnNext(One); + safe.OnCompleted(); + safe.OnNext(Two); + safe.OnError(new InvalidOperationException("ignored")); + safe.OnCompleted(); + await Assert.That(safeValues.SequenceEqual([One])).IsTrue(); + await Assert.That(safeErrors.Count).IsEqualTo(0); + await Assert.That(safeCompleted).IsEqualTo(1); + } + + /// Records observer notifications. + /// The observed value type. + private sealed class Recorder : IObserver + { + /// Gets observed values. + public List Values { get; } = []; + + /// Gets observed errors. + public List Errors { get; } = []; + + /// Gets the number of completion notifications. + public int Completed { get; private set; } + + /// + public void OnCompleted() => Completed++; + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnError(Exception error) => Errors.Add(error); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnNext(T value) => Values.Add(value); + } + + /// Observable with a disposable subscription tracker and captured observer. + /// The source value type. + private sealed class RecordingDisposableObservable : IObservable + { + /// Gets the captured observer. + public IObserver? Observer { get; private set; } + + /// Gets the number of times the source subscription was disposed. + public int DisposeCount { get; private set; } + + /// + public IDisposable Subscribe(IObserver observer) + { + Observer = observer; + return new ActionDisposable(() => DisposeCount++); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs index 669c4c8e..70f62b03 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs @@ -2,7 +2,6 @@ // 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.Runtime.CompilerServices; using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Disposables; @@ -11,7 +10,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies routing and safe-termination contracts. -public class WitnessTests +public partial class WitnessTests { /// A reusable value for one. private const int One = 1; @@ -37,9 +36,6 @@ public class WitnessTests /// A reusable value for fourteen. private const int Fourteen = 14; - /// Timeout used when awaiting a witness task that has already been driven to its terminal. - private const int TimeoutSeconds = 2; - /// Shared state value. private const string State = "state"; @@ -157,11 +153,7 @@ public async Task WitnessesCoverDisposedThrowEmptyAndSafeBranches() _ = Assert.Throws(() => safe.OnError(null!)); } - /// - /// Verifies the witness holds every notification back until its sequencer runs the queued drain, then replays - /// values and the completion through the observer in order. The dispatch is driven by a sequencer the test owns - /// so the handover is observed exactly rather than raced against a pool thread. - /// + /// The witness defers notifications until its sequencer drains them in source order. /// A task representing the asynchronous operation. [Test] public async Task WitnessOnDefersNextAndCompletedUntilTheSequencerDrainsThem() @@ -280,19 +272,19 @@ public async Task TaskAnyWitnessCompletesTrueFalseErrorsAndIgnoresLateSignals() any.OnNext(One); any.OnNext(Two); any.OnCompleted(); - await Assert.That(await WaitForAsync(any.Task)).IsTrue(); + await Assert.That(await any.Task).IsTrue(); await Assert.That(anySubscription.DisposeCount).IsEqualTo(One); TaskAnyWitness unmatched = new(static value => value > Four, CancellationToken.None); unmatched.OnNext(One); unmatched.OnCompleted(); unmatched.OnNext(Four); - await Assert.That(await WaitForAsync(unmatched.Task)).IsFalse(); + await Assert.That(await unmatched.Task).IsFalse(); InvalidOperationException predicateError = new("any-predicate"); TaskAnyWitness predicateFault = new(_ => throw predicateError, CancellationToken.None); predicateFault.OnNext(One); - var observedPredicateError = await Assert.That(() => WaitForAsync(predicateFault.Task)) + var observedPredicateError = await Assert.That(() => predicateFault.Task) .ThrowsExactly(); await Assert.That(observedPredicateError).IsSameReferenceAs(predicateError); @@ -300,7 +292,7 @@ public async Task TaskAnyWitnessCompletesTrueFalseErrorsAndIgnoresLateSignals() TaskAnyWitness sourceFault = new(CancellationToken.None); sourceFault.OnError(sourceError); sourceFault.OnCompleted(); - var observedSourceError = await Assert.That(() => WaitForAsync(sourceFault.Task)) + var observedSourceError = await Assert.That(() => sourceFault.Task) .ThrowsExactly(); await Assert.That(observedSourceError).IsSameReferenceAs(sourceError); } @@ -315,7 +307,7 @@ public async Task TaskCountWitnessCountsMatchesErrorsAndIgnoresLateSignals() all.OnNext(Two); all.OnCompleted(); all.OnNext(Three); - await Assert.That(await WaitForAsync(all.Task)).IsEqualTo(Two); + await Assert.That(await all.Task).IsEqualTo(Two); TaskCountWitness even = new(static value => value % Two == 0, CancellationToken.None); even.OnNext(One); @@ -324,12 +316,12 @@ public async Task TaskCountWitnessCountsMatchesErrorsAndIgnoresLateSignals() even.OnNext(Four); even.OnCompleted(); even.OnError(new InvalidOperationException("late")); - await Assert.That(await WaitForAsync(even.Task)).IsEqualTo(Two); + await Assert.That(await even.Task).IsEqualTo(Two); InvalidOperationException predicateError = new("count-predicate"); TaskCountWitness predicateFault = new(_ => throw predicateError, CancellationToken.None); predicateFault.OnNext(One); - var observedPredicateError = await Assert.That(() => WaitForAsync(predicateFault.Task)) + var observedPredicateError = await Assert.That(() => predicateFault.Task) .ThrowsExactly(); await Assert.That(observedPredicateError).IsSameReferenceAs(predicateError); @@ -337,7 +329,7 @@ public async Task TaskCountWitnessCountsMatchesErrorsAndIgnoresLateSignals() TaskCountWitness sourceFault = new(CancellationToken.None); sourceFault.OnError(sourceError); sourceFault.OnNext(Two); - var observedSourceError = await Assert.That(() => WaitForAsync(sourceFault.Task)) + var observedSourceError = await Assert.That(() => sourceFault.Task) .ThrowsExactly(); await Assert.That(observedSourceError).IsSameReferenceAs(sourceError); } @@ -352,7 +344,7 @@ public async Task TaskTerminalWitnessesDisposeSubscriptionsOnTerminalCancelAndDi completed.SetSubscription(completedSubscription); completed.OnCompleted(); completed.Dispose(); - await Assert.That(await WaitForAsync(completed.Task)).IsFalse(); + await Assert.That(await completed.Task).IsFalse(); await Assert.That(completedSubscription.DisposeCount).IsEqualTo(One); TaskAnyWitness alreadyStopped = new(CancellationToken.None); @@ -956,172 +948,4 @@ public async Task ListWitnessRemoveReturnsTheSameWitnessWhenTheObserverIsNotPres await Assert.That(result).IsSameReferenceAs(witness); } - - /// Asserts each witness rejects the callback or observer it cannot work without. - private static void AssertWitnessConstructorsRejectMissingCallbacks() - { - _ = Assert.Throws(static () => - { - CallbackWitness invalid = new(null!, null, null); - GC.KeepAlive(invalid); - }); - _ = Assert.Throws(static () => - { - ForwardingWitness invalid = new(null!); - GC.KeepAlive(invalid); - }); - _ = Assert.Throws(static () => - { - StatefulWitness invalid = new(State, null!, null, null); - GC.KeepAlive(invalid); - }); - } - - /// Asserts a callback witness forwards each notification, and rethrows when no error callback was given. - /// A task representing the asynchronous operation. - private static async Task AssertCallbackWitnessForwardsEachNotification() - { - List callbackValues = []; - List callbackErrors = []; - List callbackCompletions = []; - CallbackWitness callback = new(callbackValues.Add, callbackErrors.Add, callbackCompletions.Add); - InvalidOperationException callbackError = new("callback"); - callback.OnNext(One); - callback.OnError(callbackError); - callback.OnCompleted(); - await Assert.That(callbackValues.SequenceEqual([One])).IsTrue(); - await Assert.That(callbackErrors[0]).IsSameReferenceAs(callbackError); - await Assert.That(callbackCompletions[0].IsSuccess).IsTrue(); - InvalidOperationException callbackFallback = new("callback fallback"); - _ = Assert.Throws(() => - new CallbackWitness(static _ => { }, null, null).OnError(callbackFallback)); - new CallbackWitness(static _ => { }, null, null).OnCompleted(); - } - - /// Asserts a forwarding witness passes every notification through to the observer it wraps. - /// A task representing the asynchronous operation. - private static async Task AssertForwardingWitnessForwardsEachNotification() - { - Recorder forwarded = new(); - ForwardingWitness forwarding = new(forwarded); - InvalidOperationException forwardingError = new("forwarding"); - forwarding.OnNext(Two); - forwarding.OnError(forwardingError); - forwarding.OnCompleted(); - await Assert.That(forwarded.Values.SequenceEqual([Two])).IsTrue(); - await Assert.That(forwarded.Errors[0]).IsSameReferenceAs(forwardingError); - await Assert.That(forwarded.Completed).IsEqualTo(1); - } - - /// Asserts a stateful witness hands its state to every callback, and rethrows without an error callback. - /// A task representing the asynchronous operation. - private static async Task AssertStatefulWitnessForwardsEachNotificationWithItsState() - { - List statefulValues = []; - List statefulErrors = []; - List statefulCompletions = []; - StatefulWitness stateful = new( - State, - (value, state) => statefulValues.Add($"{state}:{value}"), - (error, state) => statefulErrors.Add($"{state}:{error.Message}"), - (result, state) => statefulCompletions.Add($"{state}:{result.IsSuccess}")); - InvalidOperationException statefulError = new("stateful"); - stateful.OnNext(One); - stateful.OnError(statefulError); - stateful.OnCompleted(); - await Assert.That(statefulValues.SequenceEqual([$"{State}:{One}"])).IsTrue(); - await Assert.That(statefulErrors.SequenceEqual([$"{State}:{statefulError.Message}"])).IsTrue(); - await Assert.That(statefulCompletions.SequenceEqual([$"{State}:True"])).IsTrue(); - InvalidOperationException statefulFallback = new("stateful fallback"); - _ = Assert.Throws(() => - new StatefulWitness(State, static (_, _) => { }, null, null).OnError(statefulFallback)); - new StatefulWitness(State, static (_, _) => { }, null, null).OnCompleted(); - } - - /// Asserts a safe witness drops every notification that arrives after its first terminal one. - /// A task representing the asynchronous operation. - private static async Task AssertSafeWitnessIgnoresNotificationsAfterTheTerminal() - { - List safeValues = []; - List safeErrors = []; - var safeCompleted = 0; - var safe = Witness.Safe(Witness.Create(safeValues.Add, safeErrors.Add, () => safeCompleted++)); - safe.OnNext(One); - safe.OnCompleted(); - safe.OnNext(Two); - safe.OnError(new InvalidOperationException("ignored")); - safe.OnCompleted(); - await Assert.That(safeValues.SequenceEqual([One])).IsTrue(); - await Assert.That(safeErrors.Count).IsEqualTo(0); - await Assert.That(safeCompleted).IsEqualTo(1); - } - - /// Waits for a task with a bounded timeout. - /// The task to wait for. - /// A task that completes when the supplied task completes. - /// The supplied task did not complete within the bounded timeout. - private static async Task WaitForAsync(Task task) - { - var timeout = Task.Delay(TimeSpan.FromSeconds(TimeoutSeconds)); - var completed = await Task.WhenAny(task, timeout).ConfigureAwait(false); - if (completed == timeout) - { - throw new TimeoutException("Timed out waiting for the witness task to complete."); - } - - await task.ConfigureAwait(false); - } - - /// Waits for a task with a bounded timeout and returns its result. - /// The task result type. - /// The task to wait for. - /// The task result. - private static async Task WaitForAsync(Task task) - { - await WaitForAsync((Task)task).ConfigureAwait(false); - return await task.ConfigureAwait(false); - } - - /// Records observer notifications. - /// The observed value type. - private sealed class Recorder : IObserver - { - /// Gets observed values. - public List Values { get; } = []; - - /// Gets observed errors. - public List Errors { get; } = []; - - /// Gets the number of completion notifications. - public int Completed { get; private set; } - - /// - public void OnCompleted() => Completed++; - - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void OnError(Exception error) => Errors.Add(error); - - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void OnNext(T value) => Values.Add(value); - } - - /// Observable with a disposable subscription tracker and captured observer. - /// The source value type. - private sealed class RecordingDisposableObservable : IObservable - { - /// Gets the captured observer. - public IObserver? Observer { get; private set; } - - /// Gets the number of times the source subscription was disposed. - public int DisposeCount { get; private set; } - - /// - public IDisposable Subscribe(IObserver observer) - { - Observer = observer; - return new ActionDisposable(() => DisposeCount++); - } - } } diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs index c5eecc95..405cf6ec 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs @@ -7,16 +7,9 @@ namespace ReactiveUI.Primitives.WinForms.Reactive.Tests; -/// -/// Tests for as an , exercised against a real Windows Forms -/// whose handle is created on a dedicated STA thread running a message loop, so posted work -/// runs end to end. Compiled only on Windows builds (see the csproj). -/// +/// Tests scheduler dispatch on a dedicated Windows Forms STA thread. public sealed class ControlSequencerTests { - /// Maximum time to wait for work to be marshalled onto the control thread before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Verifies the constructor rejects a null control. /// A representing the asynchronous operation. [Test] @@ -34,17 +27,17 @@ public async Task ImmediateScheduleExecutesOnControlThread() _ = scheduler.Schedule(() => completion.TrySetResult(Environment.CurrentManagedThreadId)); - var ranOnThreadId = await completion.Task.WaitAsync(WaitTimeout); + var ranOnThreadId = await completion.Task; await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); } - /// Hosts a Windows Forms on a dedicated STA thread running a message loop, exiting it and joining the thread on disposal. + /// Owns the control and its STA message loop. private sealed class ControlHarness : IDisposable { /// The thread running the Windows Forms message loop. private readonly Thread _thread; - /// Initializes a new instance of the class and waits until the control handle and message loop are running. + /// Initializes a new instance of the class with a created control handle. public ControlHarness() { using var ready = new ManualResetEventSlim(false); @@ -72,7 +65,7 @@ public ControlHarness() public void Dispose() { _ = Control.BeginInvoke(Application.ExitThread); - _ = _thread.Join(WaitTimeout); + _thread.Join(); Control.Dispose(); } } diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs index 4e397575..e921d343 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs @@ -7,16 +7,9 @@ namespace ReactiveUI.Primitives.WinForms.Tests; -/// -/// Tests for , exercised against a real Windows Forms -/// whose handle is created on a dedicated STA thread running a message loop, so posted work runs end to end. -/// Compiled only on Windows builds (see the csproj). -/// +/// Tests control dispatch on a dedicated Windows Forms STA thread. public sealed class ControlSequencerTests { - /// Maximum time to wait for work to be marshalled onto the control thread before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - /// Verifies the constructor rejects a null control. /// A representing the asynchronous operation. [Test] @@ -34,7 +27,7 @@ public async Task ImmediateScheduleExecutesOnControlThread() sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId))); - var ranOnThreadId = await completion.Task.WaitAsync(WaitTimeout); + var ranOnThreadId = await completion.Task; await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); } @@ -53,13 +46,13 @@ private sealed class DelegateWorkItem : IWorkItem public void Execute() => _action(); } - /// Hosts a Windows Forms on a dedicated STA thread running a message loop, exiting it and joining the thread on disposal. + /// Owns the control and its STA message loop. private sealed class ControlHarness : IDisposable { /// The thread running the Windows Forms message loop. private readonly Thread _thread; - /// Initializes a new instance of the class and waits until the control handle and message loop are running. + /// Initializes a new instance of the class with a created control handle. public ControlHarness() { using var ready = new ManualResetEventSlim(false); @@ -87,7 +80,7 @@ public ControlHarness() public void Dispose() { _ = Control.BeginInvoke(Application.ExitThread); - _ = _thread.Join(WaitTimeout); + _thread.Join(); Control.Dispose(); } } diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs index cb195612..9006f733 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs @@ -8,25 +8,31 @@ namespace ReactiveUI.Primitives.WinUI.Reactive.Tests; -/// -/// Tests for as an , exercised against a real WinUI -/// running on a dedicated thread so both the immediate and timer-based dispatch -/// paths run end to end. Compiled only on Windows builds (see the csproj). -/// +/// Tests scheduler execution on a dedicated WinUI queue thread. public sealed class DispatcherQueueSequencerTests { - /// Maximum time to wait for work to be marshalled onto the dispatcher-queue thread before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// How far into the future the delayed work is scheduled. - private static readonly TimeSpan ScheduleDelay = TimeSpan.FromMilliseconds(50); - /// Verifies the constructor rejects a null dispatcher queue. /// A representing the asynchronous operation. [Test] public async Task ConstructorRejectsNullDispatcherQueue() => await Assert.That(static () => new DispatcherQueueSequencer(null!)).ThrowsExactly(); + /// Verifies a stopped dispatcher queue rejects scheduled work. + /// A task representing the asynchronous test operation. + [Test] + public async Task SchedulingAfterQueueShutdownThrows() + { + var harness = new DispatcherQueueHarness(); + DispatcherQueueSequencer scheduler = new(harness.DispatcherQueue); + await harness.DisposeAsync(); + var executed = false; + await Assert.That(() => scheduler.Schedule(() => executed = true)) + .ThrowsExactly(); + await Assert.That(() => scheduler.Schedule(() => executed = true)) + .ThrowsExactly(); + await Assert.That(executed).IsFalse(); + } + /// Verifies immediate work is enqueued to and executed on the dispatcher-queue thread. /// A representing the asynchronous operation. [Test] @@ -38,22 +44,22 @@ public async Task ImmediateScheduleExecutesOnQueueThread() _ = scheduler.Schedule(() => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess)); - var ranOnQueueThread = await completion.Task.WaitAsync(WaitTimeout); + var ranOnQueueThread = await completion.Task; await Assert.That(ranOnQueueThread).IsTrue(); } - /// Verifies work due in the future is executed on the dispatcher-queue thread via the queue timer. + /// Verifies due work executes on the dispatcher queue thread. /// A representing the asynchronous operation. [Test] - public async Task DelayedScheduleExecutesOnQueueThread() + public async Task DueScheduleExecutesOnQueueThread() { await using var harness = new DispatcherQueueHarness(); var scheduler = new DispatcherQueueSequencer(harness.DispatcherQueue); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _ = scheduler.Schedule(ScheduleDelay, () => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess)); + _ = scheduler.Schedule(TimeSpan.Zero, () => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess)); - var ranOnQueueThread = await completion.Task.WaitAsync(WaitTimeout); + var ranOnQueueThread = await completion.Task; await Assert.That(ranOnQueueThread).IsTrue(); } @@ -75,6 +81,6 @@ public DispatcherQueueHarness() /// /// A task representing the asynchronous operation. - public async ValueTask DisposeAsync() => await _controller.ShutdownQueueAsync().AsTask().WaitAsync(WaitTimeout); + public async ValueTask DisposeAsync() => await _controller.ShutdownQueueAsync().AsTask(); } } diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs index bbb522d0..fee1e84b 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs @@ -2,32 +2,48 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics; using System.Runtime.CompilerServices; using Microsoft.UI.Dispatching; using ReactiveUI.Primitives.Concurrency; namespace ReactiveUI.Primitives.WinUI.Tests; -/// -/// Tests for , exercised against a real WinUI -/// running on a dedicated thread so both the immediate and timer-based -/// dispatch paths run end to end. Compiled only on Windows builds (see the csproj). -/// +/// Tests dispatcher execution on a dedicated WinUI queue thread. public sealed class DispatcherQueueSequencerTests { - /// Maximum time to wait for work to be marshalled onto the dispatcher-queue thread before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// Stopwatch ticks until the delayed work falls due: one twentieth of a second, or 50 ms. - private static readonly long ScheduleDelayTicks = Stopwatch.Frequency / 20; - /// Verifies the constructor rejects a null dispatcher queue. /// A representing the asynchronous operation. [Test] public async Task ConstructorRejectsNullDispatcherQueue() => await Assert.That(static () => new DispatcherQueueSequencer(null!)).ThrowsExactly(); + /// Verifies the clock uses UTC and debugger text identifies the sequencer. + /// A task representing the asynchronous test operation. + [Test] + public async Task ClockUsesUtcAndDebuggerTextIdentifiesSequencer() + { + await using var harness = new DispatcherQueueHarness(); + DispatcherQueueSequencer sequencer = new(harness.DispatcherQueue); + await Assert.That(sequencer.Now.Offset).IsEqualTo(TimeSpan.Zero); + await Assert.That(sequencer.DebuggerDisplay).IsEqualTo(typeof(DispatcherQueueSequencer).FullName); + } + + /// Verifies a stopped dispatcher queue rejects scheduled work. + /// A task representing the asynchronous test operation. + [Test] + public async Task SchedulingAfterQueueShutdownThrows() + { + var harness = new DispatcherQueueHarness(); + DispatcherQueueSequencer sequencer = new(harness.DispatcherQueue); + await harness.DisposeAsync(); + var executed = false; + await Assert.That(() => sequencer.Schedule(new DelegateWorkItem(() => executed = true))) + .ThrowsExactly(); + await Assert.That(() => sequencer.Schedule(new DelegateWorkItem(() => executed = true))) + .ThrowsExactly(); + await Assert.That(executed).IsFalse(); + } + /// Verifies immediate work is enqueued to and executed on the dispatcher-queue thread. /// A representing the asynchronous operation. [Test] @@ -39,23 +55,23 @@ public async Task ImmediateScheduleExecutesOnQueueThread() sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess))); - var ranOnQueueThread = await completion.Task.WaitAsync(WaitTimeout); + var ranOnQueueThread = await completion.Task; await Assert.That(ranOnQueueThread).IsTrue(); } - /// Verifies work due in the future is executed on the dispatcher-queue thread via the queue timer. + /// Verifies due work executes on the dispatcher queue thread. /// A representing the asynchronous operation. [Test] - public async Task DelayedScheduleExecutesOnQueueThread() + public async Task DueScheduleExecutesOnQueueThread() { await using var harness = new DispatcherQueueHarness(); var sequencer = new DispatcherQueueSequencer(harness.DispatcherQueue); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var due = sequencer.Timestamp + ScheduleDelayTicks; + var due = sequencer.Timestamp; sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess)), due); - var ranOnQueueThread = await completion.Task.WaitAsync(WaitTimeout); + var ranOnQueueThread = await completion.Task; await Assert.That(ranOnQueueThread).IsTrue(); } @@ -92,6 +108,6 @@ public DispatcherQueueHarness() /// /// A task representing the asynchronous operation. - public async ValueTask DisposeAsync() => await _controller.ShutdownQueueAsync().AsTask().WaitAsync(WaitTimeout); + public async ValueTask DisposeAsync() => await _controller.ShutdownQueueAsync().AsTask(); } } diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs index 97da0bb6..81be19e1 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs @@ -8,19 +8,9 @@ namespace ReactiveUI.Primitives.Wpf.Reactive.Tests; -/// -/// Tests for as an , exercised against a real WPF -/// pumped on a dedicated STA thread so both the immediate and timer-based dispatch -/// paths run end to end. Compiled only on Windows builds (see the csproj). -/// +/// Tests scheduler execution on a dedicated WPF STA thread. public sealed class DispatcherSequencerTests { - /// Maximum time to wait for work to be marshalled onto the dispatcher thread before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// How far into the future the delayed work is scheduled. - private static readonly TimeSpan ScheduleDelay = TimeSpan.FromMilliseconds(50); - /// Verifies the constructor rejects a null dispatcher. /// A representing the asynchronous operation. [Test] @@ -38,26 +28,26 @@ public async Task ImmediateScheduleExecutesOnDispatcherThread() _ = scheduler.Schedule(() => completion.TrySetResult(Environment.CurrentManagedThreadId)); - var ranOnThreadId = await completion.Task.WaitAsync(WaitTimeout); + var ranOnThreadId = await completion.Task; await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); } - /// Verifies work due in the future is executed on the dispatcher thread via the dispatcher timer. + /// Verifies due work executes on the dispatcher thread. /// A representing the asynchronous operation. [Test] - public async Task DelayedScheduleExecutesOnDispatcherThread() + public async Task DueScheduleExecutesOnDispatcherThread() { using var harness = new DispatcherHarness(); var scheduler = new DispatcherSequencer(harness.Dispatcher); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _ = scheduler.Schedule(ScheduleDelay, () => completion.TrySetResult(Environment.CurrentManagedThreadId)); + _ = scheduler.Schedule(TimeSpan.Zero, () => completion.TrySetResult(Environment.CurrentManagedThreadId)); - var ranOnThreadId = await completion.Task.WaitAsync(WaitTimeout); + var ranOnThreadId = await completion.Task; await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); } - /// Hosts a WPF on a dedicated STA thread and pumps its message loop, shutting it down and joining the thread on disposal. + /// Owns a WPF dispatcher and its STA message loop. private sealed class DispatcherHarness : IDisposable { /// The thread running the dispatcher message loop. @@ -90,7 +80,7 @@ public DispatcherHarness() public void Dispose() { Dispatcher.InvokeShutdown(); - _ = _thread.Join(WaitTimeout); + _thread.Join(); } } } diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs index 9f99becb..666a6753 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs @@ -2,32 +2,32 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics; using System.Runtime.CompilerServices; using System.Windows.Threading; using ReactiveUI.Primitives.Concurrency; namespace ReactiveUI.Primitives.Wpf.Tests; -/// -/// Tests for , exercised against a real WPF -/// pumped on a dedicated STA thread so both the immediate and timer-based dispatch paths run end to end. -/// Compiled only on Windows builds (see the csproj). -/// +/// Tests dispatcher execution on a dedicated WPF STA thread. public sealed class DispatcherSequencerTests { - /// Maximum time to wait for work to be marshalled onto the dispatcher thread before failing. - private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); - - /// Stopwatch ticks until the delayed work falls due: one twentieth of a second, or 50 ms. - private static readonly long ScheduleDelayTicks = Stopwatch.Frequency / 20; - /// Verifies the constructor rejects a null dispatcher. /// A representing the asynchronous operation. [Test] public async Task ConstructorRejectsNullDispatcher() => await Assert.That(static () => new DispatcherSequencer(null!)).ThrowsExactly(); + /// Verifies the clock uses UTC and debugger text identifies the sequencer. + /// A task representing the asynchronous test operation. + [Test] + public async Task ClockUsesUtcAndDebuggerTextIdentifiesSequencer() + { + using var harness = new DispatcherHarness(); + DispatcherSequencer sequencer = new(harness.Dispatcher); + await Assert.That(sequencer.Now.Offset).IsEqualTo(TimeSpan.Zero); + await Assert.That(sequencer.DebuggerDisplay).IsEqualTo(typeof(DispatcherSequencer).FullName); + } + /// Verifies immediate work is posted to and executed on the dispatcher thread. /// A representing the asynchronous operation. [Test] @@ -39,23 +39,23 @@ public async Task ImmediateScheduleExecutesOnDispatcherThread() sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId))); - var ranOnThreadId = await completion.Task.WaitAsync(WaitTimeout); + var ranOnThreadId = await completion.Task; await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); } - /// Verifies work due in the future is executed on the dispatcher thread via the dispatcher timer. + /// Verifies due work executes on the dispatcher thread. /// A representing the asynchronous operation. [Test] - public async Task DelayedScheduleExecutesOnDispatcherThread() + public async Task DueScheduleExecutesOnDispatcherThread() { using var harness = new DispatcherHarness(); var sequencer = new DispatcherSequencer(harness.Dispatcher); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var due = sequencer.Timestamp + ScheduleDelayTicks; + var due = sequencer.Timestamp; sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId)), due); - var ranOnThreadId = await completion.Task.WaitAsync(WaitTimeout); + var ranOnThreadId = await completion.Task; await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); } @@ -74,7 +74,7 @@ private sealed class DelegateWorkItem : IWorkItem public void Execute() => _action(); } - /// Hosts a WPF on a dedicated STA thread and pumps its message loop, shutting it down and joining the thread on disposal. + /// Owns a WPF dispatcher and its STA message loop. private sealed class DispatcherHarness : IDisposable { /// The thread running the dispatcher message loop. @@ -107,7 +107,7 @@ public DispatcherHarness() public void Dispose() { Dispatcher.InvokeShutdown(); - _ = _thread.Join(WaitTimeout); + _thread.Join(); } } } diff --git a/src/tests/Shared/AsyncTestHelpers.cs b/src/tests/Shared/AsyncTestHelpers.cs deleted file mode 100644 index 603db651..00000000 --- a/src/tests/Shared/AsyncTestHelpers.cs +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System.Runtime.CompilerServices; - -namespace ReactiveUI.Primitives.Async.Tests; - -/// Shared test helpers for async observable tests. -internal static class AsyncTestHelpers -{ - /// How long waits for completion to propagate. - private static readonly TimeSpan CompletionPropagationTimeout = TimeSpan.FromSeconds(5); - - /// How often a wait re-evaluates its condition when the caller does not choose an interval. - private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMilliseconds(10); - - /// Collects all items and the completion result from an async observable. - /// The type of elements in the observable sequence. - /// The async observable to collect from. - /// Optional cancellation token. - /// A tuple containing the collected items and the completion result. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static Task<(List Items, Result? Completion)> CollectAsync( - IObservableAsync source, - CancellationToken cancellationToken = default) => - CollectAsync(source, TimeProvider.System, cancellationToken); - - /// - /// Collects all items and the completion result from an async observable using the supplied - /// for the completion-propagation deadline. - /// - /// The type of elements in the observable sequence. - /// The async observable to collect from. - /// Time provider used to compute the propagation deadline. - /// Optional cancellation token. - /// A tuple containing the collected items and the completion result. - internal static async Task<(List Items, Result? Completion)> CollectAsync( - IObservableAsync source, - TimeProvider timeProvider, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(timeProvider); - - var items = new List(); - Result? completion = null; - - await using var subscription = await source.SubscribeAsync( - (x, _) => - { - items.Add(x); - return default; - }, - null, - result => - { - completion = result; - return default; - }, - cancellationToken); - - // Wait briefly for completion to propagate - var deadline = timeProvider.GetUtcNow().Add(CompletionPropagationTimeout); - using PeriodicTimer poll = new(DefaultPollInterval); - while (completion is null && timeProvider.GetUtcNow() < deadline) - { - _ = await poll.WaitForNextTickAsync(CancellationToken.None); - } - - return (items, completion); - } - - /// Collects all items from an async observable using ToListAsync. - /// The type of elements in the observable sequence. - /// The async observable to collect from. - /// The timeout in milliseconds before the operation is cancelled. - /// A list of all collected items. - internal static async Task> ToListWithTimeoutAsync( - IObservableAsync source, - int timeoutMs = 5_000) - { - using var cts = new CancellationTokenSource(timeoutMs); - return await source.ToListAsync(cts.Token); - } - - /// Waits until the provided condition is met or the timeout expires. - /// Condition to evaluate. - /// Maximum time to wait. - /// Optional polling interval. - /// True if the condition was met before timing out. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static Task WaitForConditionAsync( - Func condition, - TimeSpan timeout, - TimeSpan? pollInterval = null) => - WaitForConditionAsync(condition, timeout, TimeProvider.System, pollInterval); - - /// Waits until the provided condition is met or the timeout expires, using the supplied for deadline calculation. - /// Condition to evaluate. - /// Maximum time to wait. - /// Time provider used to compute the wait deadline. - /// Optional polling interval. - /// True if the condition was met before timing out. - internal static async Task WaitForConditionAsync( - Func condition, - TimeSpan timeout, - TimeProvider timeProvider, - TimeSpan? pollInterval = null) - { - ArgumentNullException.ThrowIfNull(condition); - ArgumentNullException.ThrowIfNull(timeProvider); - ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero); - - var interval = pollInterval ?? DefaultPollInterval; - var deadline = timeProvider.GetUtcNow().Add(timeout); - - using PeriodicTimer poll = new(interval); - while (timeProvider.GetUtcNow() < deadline) - { - if (condition()) - { - return true; - } - - _ = await poll.WaitForNextTickAsync(CancellationToken.None); - } - - return condition(); - } -} From 2501532b8f5deb3cae4b6dc339633cb8bfe2bea1 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:42:35 +1000 Subject: [PATCH 03/11] chore: more churn --- src/Polyfills/ArgumentExceptionHelper.cs | 5 +- .../ObjectDisposedExceptionHelper.cs | 5 +- src/Primitives.Async.Shared/AsyncContext.cs | 20 ++-- .../Observables/FromAsync.Reactive.cs | 4 +- .../Operators/ContextSwitchSignalAsync.cs | 4 +- .../Operators/ParityHelpers.Reactive.cs | 10 +- .../Operators/WitnessOn.cs | 6 +- .../CoalescingDispatchScheduler.cs | 15 +-- .../DispatchWorkItemBase.cs | 4 +- .../IDispatchWorkItem.cs | 2 +- .../Disposables/ActionDisposable.cs | 2 +- .../Disposables/BooleanDisposable.cs | 5 +- .../Disposables/CancellationDisposable.cs | 16 +--- .../Disposables/DisposableBag.cs | 15 +-- .../Disposables/EmptyDisposable.cs | 2 +- .../Disposables/IsDisposed.cs | 5 +- .../Disposables/MultipleDisposable.cs | 8 +- .../Disposables/MutableDisposable.cs | 6 +- .../Disposables/OnceDisposable.cs | 8 +- .../Disposables/SingleDisposable.cs | 14 +-- .../SingleReplaceableDisposable.cs | 18 ++-- .../Disposables/SwapDisposable.cs | 6 +- .../ConcurrentWitnessCallsException.cs | 5 +- .../ConnectableSignalAsync.cs | 4 +- .../ConnectableSignalAsyncState{T}.cs | 5 +- .../Disposables/DisposableAsyncSlot.cs | 4 +- .../Disposables/MultipleDisposableAsync.cs | 26 ++--- .../SingleAssignmentDisposableAsync.cs | 5 +- .../SingleReplaceableDisposableAsync.cs | 2 +- .../Helpers/FireAndForgetHelper.cs | 8 +- .../IObservableAsync.cs | 2 +- .../IObserverAsync.cs | 4 +- .../Mixins/SignalExtensions.cs | 10 +- .../Observables/Create.cs | 21 ++-- .../Observables/Interval.cs | 3 +- .../Observables/Timer.cs | 14 +-- .../Observables/ToAsyncSignal.cs | 2 +- .../Operators/Pair.cs | 2 +- .../Operators/ParityHelpers.FilterFusions.cs | 2 +- .../ParityHelpers.OperatorFusions.cs | 8 +- .../Operators/ParityHelpers.cs | 4 + .../Operators/ReduceAsync.cs | 28 +++--- .../SignalAsyncExtensions.Bind.Rx.cs | 1 + ...AsyncExtensions.DistinctUntilChanged.Rx.cs | 1 + .../SignalAsyncExtensions.Rescue.Rx.cs | 1 + .../SignalAsyncExtensions.Scan.Rx.cs | 2 + .../SignalAsyncExtensions.SelectMany.Rx.cs | 5 +- .../SignalAsyncExtensions.Timeout.Rx.cs | 4 +- .../Operators/SignalAsyncExtensions.Zip.Rx.cs | 2 + .../Operators/SingleAsync.cs | 16 ++-- .../Operators/SingleOrDefaultAsync.cs | 50 ++++------ .../Operators/Skip.cs | 2 +- .../Operators/SkipWhile.cs | 4 +- .../Operators/SubscribeAsync.cs | 14 +-- .../Operators/TakeUntil.cs | 10 +- .../Operators/TakeWhile.cs | 4 +- .../Operators/Throttle.cs | 3 +- .../Operators/ToAsyncEnumerable.cs | 11 +-- .../Operators/ToDictionaryAsync.cs | 14 +-- .../Operators/Unique.cs | 11 +-- .../Operators/Using.cs | 4 +- .../Operators/WaitCompletionAsync.cs | 8 +- .../Signals/Base/Concurrent.cs | 30 +++--- ...yLatestSignalAsyncStateObserverLease{T}.cs | 2 +- .../Base/ReplayLatestSignalAsyncState{T}.cs | 4 +- .../Signals/Base/SerialBroadcastHelpers.cs | 8 +- .../Base/SignalAsyncStateObserverLease{T}.cs | 2 +- .../Signals/Base/SignalAsyncState{T}.cs | 12 +-- ...yLatestSignalAsyncStateObserverLease{T}.cs | 2 +- ...tatelessReplayLatestSignalAsyncState{T}.cs | 4 +- .../Base/StatelessSignalAsyncState{T}.cs | 2 +- .../ConcurrentReplayLatestSignalAsync.cs | 7 +- .../Signals/ConcurrentSignalAsync.cs | 5 +- ...currentStatelessReplayLatestSignalAsync.cs | 8 +- .../Signals/ConcurrentStatelessSignalAsync.cs | 5 +- .../Options/BehaviorSignalCreationOptions.cs | 6 +- .../Signals/Options/PublishingOption.cs | 2 +- .../ReplayLatestSignalCreationOptions.cs | 7 +- .../Signals/Options/SignalCreationOptions.cs | 7 +- .../Signals/SerialReplayLatestSignalAsync.cs | 8 +- .../Signals/SerialSignalAsync.cs | 2 +- .../SerialStatelessReplayLatestSignalAsync.cs | 9 +- .../Signals/SerialStatelessSignalAsync.cs | 2 +- .../Signals/Signal.cs | 13 ++- .../WitnessAsync.cs | 8 +- .../Advanced/ImmediateReturnSignal{T}.cs | 2 +- .../Advanced/SubscriptionSlots.cs | 6 +- .../Concurrency/ScheduledItem.cs | 23 ++--- .../Concurrency/SequencerQueue.cs | 2 +- .../Core/IObserver{TValue,TResult}.cs | 10 +- .../Core/PriorityQueue.cs | 6 +- .../Core/TimeInterval{T}.cs | 44 ++++----- src/ReactiveUI.Primitives.Core/Optional.cs | 12 +-- src/ReactiveUI.Primitives.Core/Result.cs | 9 +- .../Signals/AsyncSignal.cs | 77 +++++++-------- .../Signals/AwaitWitness.cs | 21 ++-- .../Signals/BehaviorSignalState{T}.cs | 11 +-- .../Signals/BehaviorSignal{T}.cs | 31 +++--- .../Signals/Broadcaster{T}.cs | 4 +- .../Signals/CommandExecution{TResult}.cs | 3 +- .../Signals/CommandSignal{TResult}.cs | 16 ++-- .../Signals/DelayableNotificationSignal{T}.cs | 2 +- .../Signals/IAwaitSignal{T}.cs | 17 ++-- .../Signals/ISignal{TSource,TResult}.cs | 7 +- .../Signals/ISignal{T}.cs | 2 +- .../Signals/ITaskSignal{T}.cs | 22 ++--- .../Signals/IWitnessRemovable{T}.cs | 4 +- .../Signals/KeepSignal{T}.cs | 34 +++---- .../Signals/KeepWithSignal{T,TState}.cs | 2 +- .../Signals/MapSignal{TSource,TResult}.cs | 38 ++++---- .../MapWithSignal{TSource,TState,TResult}.cs | 2 +- .../Signals/ObserverHandler.cs | 12 +-- ...ProjectedReadOnlyState{TSource,TResult}.cs | 2 +- .../Signals/ReadOnlyState{T}.cs | 11 +-- .../Signals/Signal{T}.cs | 96 +++++++++---------- .../Signals/TapWithSignal{T,TState}.cs | 2 +- .../SubscribeExtensions.cs | 80 ++++++++-------- .../ConcurrencyLimiter.cs | 34 +++---- .../Continuation.cs | 52 +++++----- .../FirstAsTaskHelper.cs | 12 +-- .../FirstAsValueTaskHelper.cs | 12 +-- .../Heartbeat.cs | 7 +- .../IDrainTarget.cs | 6 +- .../IHeartbeat.cs | 10 +- .../IStale.cs | 10 +- .../Internal/ConcurrencyRaceHelpers.cs | 18 +--- .../Internal/IndexedSubscribeHelper.cs | 2 +- .../Internal/ObservableSubscribeExtensions.cs | 2 +- .../Internal/ReduceSinkState.cs | 3 +- .../Observables.cs | 6 +- .../ObserverArrayHelpers.cs | 23 +---- .../ObserverExtensions.cs | 8 +- .../Operators/BinaryMinMaxObservable.cs | 16 ++-- .../Operators/BooleanReduceObservable.cs | 17 ++-- .../Operators/BufferUntilObservable.cs | 10 +- .../CatchAndReturnWithFactoryObservable.cs | 12 +-- .../Operators/CatchIgnoreEmptyObservable.cs | 4 +- .../Operators/CatchIgnoreObservable.cs | 7 +- .../Operators/CatchReturnObservable.cs | 6 +- .../Operators/DoOnDisposeObservable.cs | 2 +- .../Operators/DoOnSubscribeObservable.cs | 2 +- .../Operators/DropIfBusyObservable.cs | 10 +- .../Operators/FilterRegexObservable.cs | 7 +- .../Operators/LatestOrDefaultObservable.cs | 9 +- .../Operators/MinMaxObservable.cs | 17 ++-- .../Operators/NotObservable.cs | 6 +- .../Operators/PairwiseObservable.cs | 4 +- .../Operators/PartitionObservable.cs | 19 ++-- .../Operators/PropertyChangedObservable.cs | 16 ++-- .../Operators/RetryForeverObservable.cs | 6 +- .../Operators/SampleLatestObservable.cs | 20 ++-- .../Operators/ScanWithInitialObservable.cs | 9 +- .../SelectAsyncConcurrentObservable.cs | 10 +- .../SelectAsyncSequentialObservable.cs | 10 +- .../Operators/SelectConstantObservable.cs | 7 +- .../Operators/SelectLatestAsyncObservable.cs | 14 ++- .../Operators/SelectManyThenObservable.cs | 14 ++- .../Operators/ShuffleObservable.cs | 8 +- .../Operators/SkipWhileNullObservable.cs | 5 +- .../Operators/SubscribeAsyncObservable.cs | 12 ++- .../Operators/SwitchIfEmptyObservable.cs | 13 ++- .../Operators/SynchronizeAsyncObservable.cs | 15 +-- .../Operators/TakeUntilInclusiveObservable.cs | 7 +- .../Operators/TrySelectObservable.cs | 6 +- .../Operators/WaitUntilObservable.cs | 8 +- .../Operators/WhereFalseObservable.cs | 7 +- .../Operators/WhereIsNotNullObservable.cs | 7 +- .../Operators/WhereSelectObservable.cs | 12 +-- .../Operators/WhereTrueObservable.cs | 7 +- .../SingleValueSignal.cs | 5 +- .../Stale.cs | 7 +- .../TimerSinkState.cs | 10 +- .../Concurrency/MauiDispatcherSequencer.cs | 3 + .../CodeGeneration/GeneratedNames.cs | 2 +- .../CodeGeneration/PooledStringBuilder.cs | 2 +- .../CodeGeneration/SourceFileWriter.cs | 2 +- .../Helpers/InstanceTargetExtractor.cs | 2 +- .../Helpers/StaticTargetExtractor.cs | 2 +- .../Helpers/SymbolHelpers.cs | 2 +- .../Models/EventModel.cs | 4 +- .../Concurrency/CurrentThreadSequencer.cs | 2 +- .../Concurrency/WasmScheduler.cs | 2 +- .../Platforms/android/HandlerSequencer.cs | 3 +- .../Concurrency/ControlSequencer.cs | 3 + .../Concurrency/ControlSequencer.cs | 6 +- .../Concurrency/DispatcherQueueSequencer.cs | 7 +- .../Concurrency/DispatcherQueueSequencer.cs | 10 +- .../Concurrency/DispatcherSequencer.cs | 9 +- .../Concurrency/DispatcherSequencer.cs | 11 ++- .../Concurrency/CurrentThreadSequencer.cs | 2 +- .../Concurrency/ImmediateSequencer.cs | 4 +- .../Concurrency/SequencerExtensions.cs | 1 - .../Concurrency/SynchronizedSequencerQueue.cs | 5 +- .../Concurrency/ThreadPoolSequencer.cs | 2 +- .../Concurrency/VirtualClock.cs | 4 +- .../Concurrency/VirtualTimeMessages.cs | 4 +- .../VirtualTimeSequencerExtensions.cs | 5 +- ...rtualTimeSequencer{TAbsolute,TRelative}.cs | 4 +- .../VirtualTimeState{TAbsolute,TRelative}.cs | 7 +- .../Concurrency/WasmSequencer.cs | 2 +- .../Platforms/android/HandlerSequencer.cs | 3 +- src/ReactiveUI.Primitives/RxVoid.cs | 2 +- .../AsyncExtensionsComparisonBenchmarks.cs | 5 +- .../CoreRuntimeBenchmarks.cs | 6 +- .../FactoryAdapterExpansionBenchmarks.cs | 4 +- .../FactoryGcProfileBenchmarks.cs | 3 +- .../OperatorPassThroughBenchmarks.cs | 6 +- .../OperatorStatefulFilterBenchmarks.cs | 7 +- ...eratorStatefulFilterGcProfileBenchmarks.cs | 6 +- .../Program.cs | 5 +- .../ReactiveExtensionsComparisonBenchmarks.cs | 12 +-- .../SubjectHotPathGcProfileBenchmarks.cs | 7 +- .../SubjectMulticastBenchmarks.cs | 4 +- .../DisposableSlotHelperTests.cs | 4 +- .../DisposableTests.cs | 18 ++-- .../MutableDisposableTests.cs | 4 +- .../SwapDisposableTests.cs | 4 +- .../AotSafeAssertionExtensions.cs | 7 +- .../AsyncSerialGateTests.cs | 8 +- .../CustomTaskScheduler.cs | 5 +- .../ExpireOperatorTests.cs | 6 +- .../IntervalOperatorTests.cs | 9 +- .../UnhandledExceptionCapture.cs | 2 +- .../UnhandledExceptionTestExecutor.cs | 5 +- .../DiagnosticInfoTests.cs | 2 +- .../EventGeneratorTests.cs | 2 +- .../GeneratedNamesTests.cs | 2 +- .../PooledStringBuilderTests.cs | 5 +- .../CurrentThreadSequencerTests.cs | 11 +-- .../DispatchWorkItemBaseTests.cs | 8 +- .../AsyncSignalTests.cs | 35 ++++--- .../BehaviorSignalTests.cs | 28 +++--- .../BlendUniqueTests.cs | 21 ++-- .../BroadcasterTests.cs | 15 +-- .../CalmCoordinatorTests.cs | 11 +-- .../ChooseSwitchSelectTests.cs | 48 ++++------ .../CommandExecutionTests.cs | 22 +---- .../CommandSignalTests.cs | 51 +++------- .../Common/ConsumerCompilation.cs | 5 +- .../Common/FirstInlineSequencer.cs | 4 +- .../Common/ManualSequencer.cs | 5 +- .../Common/MinimalVirtualClock.cs | 2 +- .../Common/ThrowingComparer.cs | 2 +- .../ConnectableSignalTests.cs | 6 -- .../DelegateWitnessTests.cs | 2 +- .../DispatchSequencerStateTests.cs | 8 +- .../DisposableTests.cs | 10 +- .../EventPatternTests.cs | 18 ++-- .../EverySignalTests.cs | 4 +- .../ExpireCoordinatorTests.cs | 12 +-- .../ForkJoinWitnessTests.cs | 3 - .../FromAsyncSubscriptionTests.cs | 11 +-- .../FromEnumerableSignalTests.cs | 4 +- .../IsEmptySignalTests.cs | 5 +- ...LinqExtensionsTests.CombineLatestTuples.cs | 6 +- .../LoopSignalTests.cs | 6 +- .../MomentTests.cs | 2 +- .../ObservableCancellationHandlingTests.cs | 8 +- .../OnErrorResumeNextSignalTests.cs | 2 +- .../OptionalTests.cs | 6 +- .../PriorityQueueTests.cs | 4 +- .../PrioritySemaphoreSignalTests.cs | 10 +- .../R3BridgeGeneratorTests.cs | 4 +- .../ReplaySignalTests.cs | 23 +++-- .../ResultTests.cs | 6 +- .../RxNamesTests.AliasOverloads.cs | 5 +- .../RxNamesTests.Helpers.cs | 24 ++--- .../RxNamesTests.SubscribeSafe.cs | 6 +- .../RxNamesTests.cs | 20 ++-- .../SafeWitnessTests.cs | 26 +---- .../ScheduledSignalTests.cs | 4 +- .../SequencerTests.Pools.cs | 4 +- .../SequencerTests.VirtualTime.cs | 5 +- .../SequencerTests.WorkItems.cs | 11 +-- .../SequencerTests.cs | 37 +++---- .../SignalCollectTests.cs | 11 +-- .../SignalCreateTests.cs | 20 ++-- .../SignalEmitIfQuietTests.cs | 7 +- .../SignalFactoriesTests.Behavior.cs | 2 +- .../SignalFactoriesTests.Composition.cs | 6 +- .../SignalFactoriesTests.DefaultSequencer.cs | 5 +- .../SignalFactoriesTests.EventConversion.cs | 5 +- .../SignalFactoriesTests.Scheduling.cs | 1 - .../SignalFactoriesTests.cs | 19 ++-- .../SignalGetAwaiterTests.cs | 2 +- .../SignalOperatorMixinsTests.Advanced.cs | 19 ++-- .../SignalOperatorMixinsTests.Behavior.cs | 8 +- .../SignalOperatorMixinsTests.Delay.cs | 8 +- ...SignalOperatorMixinsTests.Deterministic.cs | 35 ++++--- ...lOperatorMixinsTests.PostTerminalGuards.cs | 4 +- .../SignalOperatorMixinsTests.Scheduling.cs | 15 ++- .../SignalOperatorMixinsTests.cs | 60 ++++++------ ...nalOperatorParityMixinsTests.Aggregates.cs | 18 ++-- ...lOperatorParityMixinsTests.Cancellation.cs | 4 +- ...peratorParityMixinsTests.RangeFastPaths.cs | 10 +- .../SignalOperatorParityMixinsTests.cs | 22 ++--- .../SignalRecoverTests.cs | 3 +- .../SignalTests.Aliases.cs | 8 +- .../SignalTests.cs | 79 +++++++-------- .../SinkObserverTests.cs | 31 ++---- .../SinkTerminalTests.cs | 10 +- .../ReactiveUI.Primitives.Tests/SparkTests.cs | 8 +- .../StartSignalTests.cs | 5 +- .../StateSignalTests.cs | 19 +--- .../SubscribeExtensionsTests.cs | 4 +- .../SwitchMapTests.cs | 5 +- .../SwitchWitnessTests.Generation.cs | 3 +- .../SwitchWitnessTests.cs | 15 +-- .../SyncLatestTests.WideArity.cs | 6 +- .../SyncLatestTests.cs | 16 +--- .../TaskSignalTests.FromTask.cs | 4 +- .../TaskSignalTests.cs | 4 +- .../TaskTerminalCompletionTests.cs | 6 +- .../TaskTerminalWitnessTests.cs | 6 +- .../TimeIntervalTests.cs | 2 +- .../UnfoldSignalTests.cs | 11 +-- .../UseSignalTests.cs | 23 ++--- .../WasmSequencerTests.cs | 4 +- .../WitnessOnSignalTests.cs | 3 +- .../WitnessTests.cs | 15 ++- 320 files changed, 1374 insertions(+), 1891 deletions(-) diff --git a/src/Polyfills/ArgumentExceptionHelper.cs b/src/Polyfills/ArgumentExceptionHelper.cs index d154c660..3713bcc3 100644 --- a/src/Polyfills/ArgumentExceptionHelper.cs +++ b/src/Polyfills/ArgumentExceptionHelper.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Internal; -/// -/// Polyfill for ArgumentNullException.ThrowIfNull on net462-net481; net8.0 and later compile no copy of this -/// type and alias the ArgumentExceptionHelper identifier to instead. -/// +/// Polyfill for ArgumentNullException.ThrowIfNull on target frameworks that predate it. [ExcludeFromCodeCoverage] internal static class ArgumentExceptionHelper { diff --git a/src/Polyfills/ObjectDisposedExceptionHelper.cs b/src/Polyfills/ObjectDisposedExceptionHelper.cs index c74b6361..3af19522 100644 --- a/src/Polyfills/ObjectDisposedExceptionHelper.cs +++ b/src/Polyfills/ObjectDisposedExceptionHelper.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Internal; -/// -/// Polyfill for ObjectDisposedException.ThrowIf on net462-net481; net8.0 and later compile no copy of this -/// type and alias the ObjectDisposedExceptionHelper identifier to instead. -/// +/// Polyfill for ObjectDisposedException.ThrowIf on target frameworks that predate it. [ExcludeFromCodeCoverage] internal static class ObjectDisposedExceptionHelper { diff --git a/src/Primitives.Async.Shared/AsyncContext.cs b/src/Primitives.Async.Shared/AsyncContext.cs index 67bc92bb..b95d9352 100644 --- a/src/Primitives.Async.Shared/AsyncContext.cs +++ b/src/Primitives.Async.Shared/AsyncContext.cs @@ -12,8 +12,7 @@ namespace ReactiveUI.Primitives.Async.Reactive; namespace ReactiveUI.Primitives.Async; #endif -/// Represents an asynchronous execution context that encapsulates a specific SynchronizationContext or TaskScheduler for controlling the scheduling of asynchronous operations. -/// Contexts wrap a synchronization context, task scheduler, or sequencer. Default uses the default task scheduler. +/// An execution context that pins continuations to a , a , or an . [System.Diagnostics.DebuggerDisplay( "AsyncContext: SynchronizationContext = {SynchronizationContext}, TaskScheduler = {TaskScheduler}, Sequencer = {Sequencer}")] public sealed record AsyncContext @@ -23,7 +22,7 @@ private AsyncContext() { } - /// Gets the default instance of the AsyncContext class. + /// Gets the context that schedules continuations on the default task scheduler. public static AsyncContext Default { get; } = new(); /// Gets the synchronization context to use for marshaling callbacks and continuations. @@ -90,8 +89,8 @@ public static AsyncContext GetCurrent() } /// Creates an awaitable that switches execution to the associated asynchronous context. - /// true to always yield execution to the context, even if already in the correct context; otherwise, false to avoid - /// yielding if already in the context. + /// true to always yield execution to the context, even when the calling thread is on it; + /// otherwise, false to continue inline when the context matches. /// A cancellation token that can be used to cancel the context switch operation. /// An awaitable that completes when execution has switched to the asynchronous context. public AsyncContextSwitcherAwaitable SwitchContextAsync(bool forceYielding, CancellationToken cancellationToken) => @@ -99,8 +98,8 @@ public AsyncContextSwitcherAwaitable SwitchContextAsync(bool forceYielding, Canc /// Provides an awaitable that switches execution to a specified asynchronous context, optionally forcing a yield and supporting cancellation. /// The asynchronous context to which execution should be switched when awaited. - /// true to always yield execution even if already in the target context; otherwise, false to avoid yielding if - /// already in the specified context. + /// true to always yield execution even when the calling thread is on the target context; + /// otherwise, false to continue inline when the context matches. /// A cancellation token that can be used to cancel the await operation before the continuation is scheduled. /// Cancellation invokes the continuation immediately; GetResult then throws OperationCanceledException. [System.Diagnostics.DebuggerDisplay("AsyncContextSwitcherAwaitable: IsCompleted = {IsCompleted}, ForceYielding = {ForceYielding}")] @@ -117,7 +116,7 @@ public readonly record struct AsyncContextSwitcherAwaitable( [MethodImpl(MethodImplOptions.AggressiveInlining)] public void GetResult() => CancellationToken.ThrowIfCancellationRequested(); - /// Returns an awaiter for this AsyncContextSwitcherAwaitable instance, enabling use of the await keyword to asynchronously switch execution context. + /// Returns this instance, which acts as its own awaiter. /// An awaiter that can be used to await this instance and perform an asynchronous context switch. public AsyncContextSwitcherAwaitable GetAwaiter() => this; @@ -181,8 +180,7 @@ private sealed class ContinuationWorkItem(Action continuation) : IWorkItem [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "CA1812:Avoid uninstantiated internal classes", - Justification = - "Kept as an internal adapter for generator and test smoke scenarios that need TaskScheduler-shaped sequencer execution.")] + Justification = "The adapter is constructed outside this assembly, not by the library itself.")] internal sealed class SequencerTaskScheduler(ISequencer scheduler) : TaskScheduler { /// Gets the sequencer used by this task-scheduler adapter. @@ -195,7 +193,7 @@ internal sealed class SequencerTaskScheduler(ISequencer scheduler) : TaskSchedul /// Attempts inline execution through the adapter. /// The task to attempt to execute inline. - /// Whether the task was previously queued. + /// Whether the task has been queued to this scheduler before the call. /// The result of the protected implementation. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal bool TryExecuteTaskInlineForTesting(Task task, bool taskWasPreviouslyQueued) => diff --git a/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs b/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs index 9fcba034..550f3571 100644 --- a/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs +++ b/src/Primitives.Async.Shared/Observables/FromAsync.Reactive.cs @@ -28,9 +28,7 @@ public static partial class SignalAsyncReactiveExtensions /// An observable sequence that emits a single value of when the factory function completes, /// followed by a completion notification. /// Thrown if is . - /// The returned observable executes the factory function as a background job. The sequence emits - /// after the factory completes and then signals completion. Cancellation is supported - /// via the provided token. + /// The factory is invoked once per subscription. [SuppressMessage( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", diff --git a/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs b/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs index 39136346..d89a2911 100644 --- a/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs +++ b/src/Primitives.Async.Shared/Operators/ContextSwitchSignalAsync.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Async; /// The type of elements in the observable sequence. /// The source observable whose notifications will be context-switched. /// The async context to switch notifications onto. -/// Whether to force yielding even if already on the target context. +/// Whether to yield even when the calling thread is on the target context. public sealed class ContextSwitchSignalAsync( IObservableAsync source, AsyncContext asyncContext, @@ -28,7 +28,7 @@ ValueTask IObservableAsync.SubscribeAsync( /// An observer that switches each notification onto the specified async context before forwarding. /// The downstream observer to forward notifications to. /// The async context to switch onto. - /// Whether to force yielding even if already on the target context. + /// Whether to yield even when the calling thread is on the target context. internal sealed class ContextSwitchWitness( IObserverAsync observer, AsyncContext asyncContext, diff --git a/src/Primitives.Async.Shared/Operators/ParityHelpers.Reactive.cs b/src/Primitives.Async.Shared/Operators/ParityHelpers.Reactive.cs index 56a0db24..e08328fc 100644 --- a/src/Primitives.Async.Shared/Operators/ParityHelpers.Reactive.cs +++ b/src/Primitives.Async.Shared/Operators/ParityHelpers.Reactive.cs @@ -18,7 +18,7 @@ namespace ReactiveUI.Primitives.Async; [SuppressMessage( "StyleCop.CSharp.OrderingRules", "SA1201:ElementsShouldAppearInTheCorrectOrder", - Justification = "C# 14 extension methods")] + Justification = "Extension block members cannot be reordered into the conventional element order.")] public static partial class SignalAsyncReactiveExtensions { /// Async-native parity helper operators for an observable source sequence. @@ -35,7 +35,7 @@ public IObservableAsync AsSignal() return new AsRxVoidSignal(source); } - /// Uses ObserveOn only when a context is provided. + /// Observes the source on the supplied context, handing back the source unchanged when no context is supplied. /// The target async context, or to leave the sequence unchanged. /// The source sequence, optionally observed on the provided context. public IObservableAsync ObserveOnSafe(AsyncContext? asyncContext) @@ -45,7 +45,7 @@ public IObservableAsync ObserveOnSafe(AsyncContext? asyncContext) return asyncContext is null ? source : new WitnessOnSignal(source, asyncContext, false); } - /// Uses ObserveOn only when a context is provided. + /// Observes the source on the supplied context, handing back the source unchanged when no context is supplied. /// The target async context, or to leave the sequence unchanged. /// Whether to force yielding when switching context. /// The source sequence, optionally observed on the provided context. @@ -56,7 +56,7 @@ public IObservableAsync ObserveOnSafe(AsyncContext? asyncContext, bool forceY return asyncContext is null ? source : new WitnessOnSignal(source, asyncContext, forceYielding); } - /// Uses ObserveOn only when a scheduler is provided. + /// Observes the source on the supplied scheduler, handing back the source unchanged when no scheduler is supplied. /// The target scheduler, or to leave the sequence unchanged. /// The source sequence, optionally observed on the provided scheduler. public IObservableAsync ObserveOnSafe(TaskScheduler? taskScheduler) @@ -68,7 +68,7 @@ public IObservableAsync ObserveOnSafe(TaskScheduler? taskScheduler) : new WitnessOnSignal(source, AsyncContext.From(taskScheduler), false); } - /// Uses ObserveOn only when a scheduler is provided. + /// Observes the source on the supplied scheduler, handing back the source unchanged when no scheduler is supplied. /// The target scheduler, or to leave the sequence unchanged. /// Whether to force yielding when switching context. /// The source sequence, optionally observed on the provided scheduler. diff --git a/src/Primitives.Async.Shared/Operators/WitnessOn.cs b/src/Primitives.Async.Shared/Operators/WitnessOn.cs index 32d900fc..5e890dd6 100644 --- a/src/Primitives.Async.Shared/Operators/WitnessOn.cs +++ b/src/Primitives.Async.Shared/Operators/WitnessOn.cs @@ -24,8 +24,8 @@ public static partial class SignalAsyncReactiveExtensions { /// Wraps the source observable so that observer callbacks are invoked on the specified async context. /// The async context on which observer callbacks should be invoked. - /// When true, forces an asynchronous yield before invoking each callback, even if already on the target - /// context. + /// When true, forces an asynchronous yield before invoking each callback, even when + /// the calling thread is on the target context. /// An observable sequence whose observer callbacks execute on the specified context. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync WitnessOn(AsyncContext asyncContext, bool forceYielding) => @@ -74,7 +74,7 @@ public IObservableAsync WitnessOn(TaskScheduler taskScheduler) => /// Configures the observable sequence to notify observers on the specified scheduler. /// The scheduler on which to observe and deliver notifications to observers. Cannot be null. - /// true to force yielding to the scheduler even if already on the target context; otherwise, false. + /// true to force yielding to the scheduler even when the calling thread is on the target context; otherwise, false. /// An observable sequence whose notifications are delivered on the specified scheduler. public IObservableAsync WitnessOn(ISequencer scheduler, bool forceYielding) { diff --git a/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs b/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs index 6fa5d1fc..43fd7b74 100644 --- a/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs +++ b/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs @@ -9,11 +9,12 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; -/// -/// Base for UI-thread dispatchers that coalesces dispatcher posts: immediate work is queued -/// and drained one batch per post. A sealed platform scheduler supplies its dispatcher (and -/// optionally a native delayed path via ). -/// +/// Base for UI-thread dispatchers that drains queued work one batch per dispatcher post. +/// Work runs on the dispatcher thread; scheduling from that thread queues the item for the next posted batch +/// rather than running it inline, so a scheduled action never re-enters the caller. The disposable a +/// Schedule overload returns suppresses work that has not started and disposes what a started action +/// returned. A platform scheduler supplies and may replace the delayed path by overriding +/// . [System.Diagnostics.DebuggerDisplay("CoalescingDispatchScheduler: ReadyCount = {_readyCount}, DrainPosted = {_drainPosted}")] public abstract class CoalescingDispatchScheduler : LocalScheduler { @@ -26,7 +27,7 @@ public abstract class CoalescingDispatchScheduler : LocalScheduler /// Schedules delays before work returns to the dispatcher. private readonly IScheduler _delayScheduler; - /// Approximate number of ready items; snapshots a drain batch. + /// Approximate count of ready items; bounds the batch one drain dequeues. private int _readyCount; /// Gate that keeps at most one queued drain callback pending. @@ -106,7 +107,7 @@ protected virtual IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime return Disposable.Empty; })); - /// Re-posts a drain if work is still queued; platform adapters call this when the dispatcher becomes ready. + /// Posts a drain when queued work remains; platform adapters call this when the dispatcher becomes ready. [MethodImpl(MethodImplOptions.AggressiveInlining)] protected void RequestDrain() => PostDrain(); diff --git a/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs b/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs index 0704be55..5fb7f74c 100644 --- a/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs +++ b/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs @@ -44,7 +44,7 @@ protected DispatchWorkItemBase( /// Gets a value indicating whether the work item has been cancelled. internal bool IsDisposed => Volatile.Read(ref _isDisposed) != 0; - /// Runs the scheduled action unless it has already been cancelled, disposing its result if a cancel races the start. + /// Runs the scheduled action unless it has been cancelled, disposing its result when a cancel races the start. public void Run() { if (IsDisposed) @@ -72,7 +72,7 @@ public void Run() /// for the first caller, which owns releasing the item's resources. protected bool TryClaimDispose() => Interlocked.Exchange(ref _isDisposed, 1) == 0; - /// Releases the disposable the action returned once it has started, so a late cancel still tears it down. + /// Disposes whatever the started action returned, so a cancel arriving after the start tears it down. [MethodImpl(MethodImplOptions.AggressiveInlining)] protected void ReleaseStartedWork() => Interlocked.Exchange(ref _disposable, Disposable.Empty)?.Dispose(); } diff --git a/src/Primitives.Platform.Reactive.Shared/IDispatchWorkItem.cs b/src/Primitives.Platform.Reactive.Shared/IDispatchWorkItem.cs index b23559bb..0c2f3546 100644 --- a/src/Primitives.Platform.Reactive.Shared/IDispatchWorkItem.cs +++ b/src/Primitives.Platform.Reactive.Shared/IDispatchWorkItem.cs @@ -7,6 +7,6 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// A scheduled, cancellable unit of work drained by a . internal interface IDispatchWorkItem : IDisposable { - /// Executes the work unless it has already been cancelled. + /// Executes the work unless it has been cancelled. void Run(); } diff --git a/src/ReactiveUI.Disposables/Disposables/ActionDisposable.cs b/src/ReactiveUI.Disposables/Disposables/ActionDisposable.cs index 08e4f4fe..a916a528 100644 --- a/src/ReactiveUI.Disposables/Disposables/ActionDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/ActionDisposable.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Disposables; -/// An that runs the supplied exactly once on . Replaces new ActionDisposable(Action). +/// An that runs the supplied on the first call and does nothing on later calls. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ActionDisposable : IsDisposed { diff --git a/src/ReactiveUI.Disposables/Disposables/BooleanDisposable.cs b/src/ReactiveUI.Disposables/Disposables/BooleanDisposable.cs index 16db9147..0fd40cd6 100644 --- a/src/ReactiveUI.Disposables/Disposables/BooleanDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/BooleanDisposable.cs @@ -15,16 +15,13 @@ public sealed class BooleanDisposable : IsDisposed private int _isDisposed; /// Gets a value indicating whether this instance is disposed. - /// - /// true if this instance is disposed; otherwise, false. - /// public bool IsDisposed => Volatile.Read(ref _isDisposed) != 0; /// Gets the debugger display text. [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Latches the disposed flag; repeated calls have no further effect. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => Interlocked.Exchange(ref _isDisposed, 1); } diff --git a/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs b/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs index c57f84ae..a464807f 100644 --- a/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs @@ -4,19 +4,19 @@ namespace ReactiveUI.Primitives.Disposables; -/// A disposable that cancels an owned when disposed. +/// A disposable that cancels a when disposed; the source itself is left undisposed. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class CancellationDisposable : IsDisposed { - /// Cancellation source owned by this disposable. + /// The source cancelled on disposal. private readonly CancellationTokenSource _cts; /// Disposed latch; 0 when alive, 1 once disposed. private int _isDisposed; /// Initializes a new instance of the class. - /// The CTS. + /// The source to cancel when this instance is disposed. /// is . public CancellationDisposable(CancellationTokenSource cts) => _cts = cts ?? throw new ArgumentNullException(nameof(cts)); @@ -27,16 +27,10 @@ public CancellationDisposable() { } - /// Gets the token. - /// - /// The token. - /// + /// Gets the token that enters the cancelled state when this instance is disposed. public CancellationToken Token => _cts.Token; /// Gets a value indicating whether this instance is disposed. - /// - /// true if this instance is disposed; otherwise, false. - /// public bool IsDisposed => Volatile.Read(ref _isDisposed) != 0; /// Gets the debugger display text. @@ -44,7 +38,7 @@ public CancellationDisposable() [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Cancels the source on the first call; repeated calls have no further effect. public void Dispose() { // Atomic run-once latch so concurrent disposal cannot cancel the source twice. diff --git a/src/ReactiveUI.Disposables/Disposables/DisposableBag.cs b/src/ReactiveUI.Disposables/Disposables/DisposableBag.cs index 36c9fdbd..687a4be3 100644 --- a/src/ReactiveUI.Disposables/Disposables/DisposableBag.cs +++ b/src/ReactiveUI.Disposables/Disposables/DisposableBag.cs @@ -4,17 +4,8 @@ namespace ReactiveUI.Primitives.Disposables; -/// -/// A small composite-disposable replacement specialised for the common 2-slot -/// "subscription + sink" pair found throughout this codebase. Avoids the -/// backing field of -/// System.Reactive.Disposables.CompositeDisposable. -/// -/// -/// The first two added entries are stored inline. A third or later entry causes a fall-back -/// to a heap-allocated array. Disposal is idempotent and disposes every contained entry, -/// in registration order, exactly once. -/// +/// A composite disposable that holds its first two entries in inline fields and spills further entries into a heap-allocated array. +/// Disposal is idempotent and disposes every contained entry, in registration order, exactly once. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DisposableBag : IsDisposed { @@ -77,7 +68,7 @@ public DisposableBag(IDisposable first, IDisposable second, IDisposable third) [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Adds a disposable to the bag. If the bag is already disposed, the supplied disposable is disposed immediately. + /// Adds a disposable to the bag, disposing it immediately if the bag is disposed and ignoring a argument. /// The disposable to add. public void Add(IDisposable disposable) { diff --git a/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs b/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs index 86e6eaeb..92d07b89 100644 --- a/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Disposables; -/// A no-op singleton used in place of EmptyDisposable.Instance. +/// A no-op whose shared instance stands in for the absence of a resource. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class EmptyDisposable : IDisposable { diff --git a/src/ReactiveUI.Disposables/Disposables/IsDisposed.cs b/src/ReactiveUI.Disposables/Disposables/IsDisposed.cs index 571a0ac6..1137e1ce 100644 --- a/src/ReactiveUI.Disposables/Disposables/IsDisposed.cs +++ b/src/ReactiveUI.Disposables/Disposables/IsDisposed.cs @@ -4,13 +4,10 @@ namespace ReactiveUI.Primitives.Disposables; -/// Is Disposed. +/// An that reports whether it has been disposed. /// public interface IsDisposed : IDisposable { /// Gets a value indicating whether this instance is disposed. - /// - /// true if this instance is disposed; otherwise, false. - /// bool IsDisposed { get; } } diff --git a/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs b/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs index 8c36bcd1..96268934 100644 --- a/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs @@ -128,7 +128,7 @@ public int Count [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IDisposable Create(params IDisposable[] disposables) => new MultipleDisposableBase(disposables); - /// Adds a disposable to the or disposes it immediately if the pocket is already disposed. + /// Adds a disposable to the group, or disposes it immediately when the group is disposed. /// Disposable to add. /// is . public void Add(IDisposable item) @@ -280,15 +280,15 @@ public void CopyTo(IDisposable[] array, int arrayIndex) [MethodImpl(MethodImplOptions.AggressiveInlining)] public IEnumerator GetEnumerator() => Snapshot().GetEnumerator(); - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Disposes every held disposable and marks the group disposed; repeated calls have no further effect. public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - /// Releases unmanaged and - optionally - managed resources. - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// Releases the held disposables in registration order, the two inline slots first, then the overflow entries. + /// to dispose the held disposables; to release nothing. protected virtual void Dispose(bool disposing) { if (!disposing) diff --git a/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs b/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs index 822d45de..d5da0447 100644 --- a/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs @@ -7,10 +7,10 @@ namespace ReactiveUI.Primitives.Disposables; /// -/// A disposable holder whose inner disposable can be re-assigned. The previous inner +/// A disposable holder whose inner disposable can be re-assigned. The displaced inner /// disposable is NOT disposed when replaced (in contrast to ). -/// Once this object is disposed, any subsequently assigned inner disposable is disposed -/// immediately. Replaces MultipleAssignmentDisposable. +/// Once this object is disposed, any inner disposable assigned afterwards is disposed +/// immediately. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class MutableDisposable : IsDisposed diff --git a/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs b/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs index 2f79d61b..ac3ad4f7 100644 --- a/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs @@ -5,10 +5,10 @@ namespace ReactiveUI.Primitives.Disposables; /// -/// A disposable holder whose inner disposable can be set exactly once. -/// Replaces SingleAssignmentDisposable. Subsequent assignments throw -/// ; if the holder has been disposed before -/// assignment, the supplied disposable is disposed immediately and no exception is thrown. +/// A disposable holder whose inner disposable can be set exactly once. A second +/// assignment throws ; if the holder was disposed +/// before the assignment, the supplied disposable is disposed immediately and no exception +/// is thrown. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class OnceDisposable : IsDisposed diff --git a/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs b/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs index df798b81..76b1303d 100644 --- a/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Disposables; -/// Single-assignment disposable slot. +/// A disposable slot that accepts one assignment: a second assignment throws, and a value assigned after disposal is disposed immediately. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public class SingleDisposable : IsDisposed { @@ -50,7 +50,7 @@ public SingleDisposable(IDisposable disposable, Action? action) /// Assigns the disposable held by this slot. /// The disposable. /// is . - /// The slot already has an assignment. + /// The slot holds an earlier assignment. public void Create(IDisposable disposable) { ArgumentExceptionHelper.ThrowIfNull(disposable); @@ -70,15 +70,15 @@ public void Create(IDisposable disposable) throw new InvalidOperationException($"The {nameof(disposable)} slot has already been assigned."); } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Disposes the assigned value and blocks further assignments; repeated calls have no further effect. public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - /// Releases unmanaged and - optionally - managed resources. - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// Runs the constructor-supplied action and then disposes the assigned value, once. + /// to dispose the assigned value; to release nothing. protected virtual void Dispose(bool disposing) { if (!disposing) @@ -103,8 +103,8 @@ private sealed class DisposedMarker : IDisposable /// public void Dispose() { - // Intentionally empty: a reference-identity sentinel marking an already-disposed slot. It is only - // ever compared with ReferenceEquals and never itself disposed, so this body is unreachable. + // Intentionally empty: a reference-identity sentinel marking a disposed slot. It is only ever + // compared with ReferenceEquals and never itself disposed, so this body is unreachable. } } } diff --git a/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs b/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs index f87bad39..65fc271b 100644 --- a/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs @@ -44,9 +44,6 @@ public SingleReplaceableDisposable(IDisposable disposable, Action? action) } /// Gets a value indicating whether this instance is disposed. - /// - /// true if this instance is disposed; otherwise, false. - /// public bool IsDisposed => ReferenceEquals(Volatile.Read(ref _disposable), DisposedSentinel); /// Gets the debugger display text. @@ -54,8 +51,8 @@ public SingleReplaceableDisposable(IDisposable disposable, Action? action) [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Creates the specified disposable. - /// The disposable. + /// Assigns the inner disposable and disposes the value it displaces; once this slot is disposed the incoming value is disposed instead. + /// The disposable to take as the new inner value. /// is . public void Create(IDisposable disposable) { @@ -81,16 +78,15 @@ public void Create(IDisposable disposable) } } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Disposes the inner value and blocks further assignments; repeated calls have no further effect. public void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Dispose(true); GC.SuppressFinalize(this); } - /// Releases unmanaged and - optionally - managed resources. - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + /// Disposes the inner value and then invokes the constructor-supplied action, once. + /// when invoked from . protected virtual void Dispose(bool disposing) { var old = Interlocked.Exchange(ref _disposable, DisposedSentinel); @@ -110,8 +106,8 @@ private sealed class DisposedMarker : IDisposable /// public void Dispose() { - // Intentionally empty: a reference-identity sentinel marking an already-disposed slot. It is only - // ever compared with ReferenceEquals and never itself disposed, so this body is unreachable. + // Intentionally empty: a reference-identity sentinel marking a disposed slot. It is only ever + // compared with ReferenceEquals and never itself disposed, so this body is unreachable. } } } diff --git a/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs b/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs index 7445557b..2d4aeb80 100644 --- a/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs @@ -7,10 +7,10 @@ namespace ReactiveUI.Primitives.Disposables; /// -/// A disposable holder whose inner disposable can be re-assigned. The previous inner +/// A disposable holder whose inner disposable can be re-assigned. The displaced inner /// disposable is disposed when replaced (in contrast to ). -/// Once this object is disposed, any subsequently assigned inner disposable is disposed -/// immediately. Replaces SerialDisposable. +/// Once this object is disposed, any inner disposable assigned afterwards is disposed +/// immediately. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class SwapDisposable : IsDisposed diff --git a/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs b/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs index 95bfda91..4eca4b62 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConcurrentWitnessCallsException.cs @@ -33,10 +33,7 @@ public ConcurrentWitnessCallsException(string message) { } - /// - /// Initializes a new instance of the class with a specified error message - /// and a reference to the inner exception that is the cause of this exception. - /// + /// Initializes a new instance of the class with a message and an inner exception. /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception. public ConcurrentWitnessCallsException(string message, Exception innerException) diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs index a719cb1f..46fabde7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsync.cs @@ -17,7 +17,7 @@ public sealed class ConnectableSignalAsync : IObservableAsync, IDisposable { /// Initializes a new instance of the class. /// The source signal to multicast. - /// The signal used to broadcast notifications to multiple observers. + /// The signal that multicasts source notifications to the subscribed observers. public ConnectableSignalAsync(IObservableAsync source, ISignalAsync signal) => State = new(source, signal); @@ -25,7 +25,7 @@ public ConnectableSignalAsync(IObservableAsync source, ISignalAsync signal private ConnectableSignalAsyncState State { get; } /// Subscribes the signal to the source, or returns the live connection when one exists. - /// A cancellation token that can be used to cancel the asynchronous connection operation. + /// A token that cancels connection establishment. /// A handle whose disposal drops the source subscription, allowing a later call to reconnect. /// This instance has been disposed, or /// was cancelled. diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs index 7ecc671d..02e7a655 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncState{T}.cs @@ -20,7 +20,7 @@ internal sealed class ConnectableSignalAsyncState : IDisposable Justification = "The helper class that drives this state enters the gate directly.")] internal readonly AsyncSerialGate Gate = new(); - /// The monitor used to make synchronous disposal idempotent. + /// Guards the disposal latch so only the first caller tears the state down. private readonly Lock _disposalGate = new(); /// Initializes a new instance of the class. @@ -48,8 +48,7 @@ public ConnectableSignalAsyncState(IObservableAsync source, ISignalAsync s /// Gets or sets a value indicating whether disposal has been claimed. internal bool IsDisposed { get; set; } - /// Gets the token cancelled when the connectable signal is disposed, captured at construction because - /// makes throw. + /// Gets the disposal token, captured at construction because makes throw. internal CancellationToken DisposedCancellationToken { get; } /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs index 02b68d82..aa266e5a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs @@ -75,7 +75,7 @@ public static bool IsDisposed(IAsyncDisposable? slot) => /// Retries a swap when the slot changed after the caller's observation. /// The slot to replace. /// The incoming disposable. - /// The previously observed slot value. + /// The slot value the caller observed. /// Disposal of the replaced value, or the incoming value when the slot is closed. internal static ValueTask SwapObservedAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value, IAsyncDisposable? current) { @@ -101,7 +101,7 @@ internal static ValueTask SwapObservedAsync(ref IAsyncDisposable? slot, IAsyncDi internal static InvalidOperationException CreateAlreadyAssignedException() => new("Disposable is already assigned."); - /// Singleton no-op disposable used to mark closed async-disposable slots. + /// Sentinel no-op disposable that marks a closed slot. private sealed class DisposedAsyncDisposable : IAsyncDisposable { /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs index ce047d27..6400f71b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async.Disposables; -/// -/// Represents a thread-safe collection of asynchronous disposable objects that are disposed together as a group. -/// Provides methods to add, remove, and asynchronously dispose contained resources as a single operation. -/// +/// A thread-safe collection of asynchronous disposables whose lifetimes are owned and released as one group. /// Disposal is one-way: a disposed collection holds nothing, and adding to it disposes the incoming item /// instead of storing it. Safe for concurrent access from several threads. [System.Diagnostics.DebuggerDisplay("MultipleDisposableAsync: Count = {_count}, IsDisposed = {_isDisposed}")] @@ -21,7 +18,7 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable /// Used-slot count at or below which a remove leaves the array uncompacted. private const int ShrinkThreshold = 16; - /// Divisor used to decide whether a remove triggers compaction (count * 4 < length). + /// Occupancy divisor: a remove compacts when count multiplied by this falls below the array's length. private const int ShrinkOccupancyDivisor = 4; /// Factor the backing array's capacity is multiplied by when it overflows. @@ -162,8 +159,8 @@ public ValueTask AddAsync(IAsyncDisposable item) /// Removes the specified item from the collection and disposes it asynchronously. /// The item to remove and dispose. Cannot be null. - /// A task that represents the asynchronous remove operation. The task result is if the item - /// was found and removed; otherwise, . + /// when the item was found, removed and disposed; otherwise, + /// . /// An item this collection does not hold is left alone, not disposed. public async ValueTask Remove(IAsyncDisposable item) { @@ -252,12 +249,10 @@ public bool Contains(IAsyncDisposable item) } /// Copies the elements of the collection to the specified array, starting at the given array index. - /// The one-dimensional array of IAsyncDisposable elements that is the destination of the elements copied from the - /// collection. The array must have zero-based indexing. - /// The zero-based index in the destination array at which copying begins. Must be non-negative and less than the - /// length of the array. - /// Thrown when arrayIndex is less than zero, greater than or equal to the length of array, or when there is not - /// enough space from arrayIndex to the end of array to accommodate all elements in the collection. + /// The zero-based destination array. + /// The index in at which copying begins. + /// falls outside + /// , or the space from it to the end of the array cannot hold every item. /// A disposed collection copies nothing and raises nothing. public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) { @@ -324,10 +319,7 @@ public async ValueTask DisposeAsync() } } - /// - /// Returns an enumerator that iterates a snapshot of the non-null disposables in the collection. - /// The snapshot is taken under the gate; subsequent mutations do not affect the enumerator. - /// + /// Returns an enumerator over a snapshot taken under the gate, so later mutations do not affect it. /// An enumerator over a snapshot of the collection's disposables. public IEnumerator GetEnumerator() { diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs index 2daec78b..2f126f12 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleAssignmentDisposableAsync.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Async.Disposables; -/// -/// Represents an asynchronously disposable resource that allows a single assignment of its underlying disposable. Once -/// disposed, further assignments will dispose the assigned resource immediately. -/// +/// Owns one asynchronous disposable: the first assignment takes, and an assignment made after disposal disposes its argument. /// A second assignment throws , so this type suits the common shape /// where a subscription handle has to be stored before the work it cancels can produce it. [System.Diagnostics.DebuggerDisplay("SingleAssignmentDisposableAsync: IsDisposed = {IsDisposed}, Current = {_current}")] diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs index 646260bc..d9dc6ad0 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Async.Disposables; -/// Provides a thread-safe mechanism for managing a single asynchronously disposable resource that can be replaced or disposed of serially. +/// Owns one asynchronous disposable at a time, replacing it on assignment. /// Exactly one resource is live at a time: each assignment disposes the one it displaces, and disposing this /// instance closes the slot so a further assignment disposes its argument. Safe to use from several threads. [System.Diagnostics.DebuggerDisplay("SingleReplaceableDisposableAsync: Current = {_current}")] diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs index 419e96b7..552c90ba 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs @@ -12,8 +12,8 @@ namespace ReactiveUI.Primitives.Async.Helpers; /// public static class FireAndForgetHelper { - /// Executes an async action as fire-and-forget, swallowing all exceptions. - /// The async action to execute. + /// Starts with no task to await, routing any failure to . + /// The asynchronous action to start. [SuppressMessage( "Concurrency", "SST1905:Do not use async void", @@ -21,14 +21,14 @@ public static class FireAndForgetHelper [SuppressMessage( "ReSharper", "AsyncVoidMethod", - Justification = "This is a fire-and-forget helper.")] + Justification = "There is no caller to hand a Task back to, so failures go to the global handler instead.")] [ExcludeFromCodeCoverage] public static async void Run(Func action) => await RunAsync(action).ConfigureAwait(false); /// Runs the action and reports failures to the unhandled exception handler. /// The action to execute. - /// The action and any exception reporting. + /// A task that completes once the action and any failure reporting have run. internal static async ValueTask RunAsync(Func action) { ArgumentExceptionHelper.ThrowIfNull(action); diff --git a/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs index 4d507c84..1a01f50e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/IObservableAsync.cs @@ -12,7 +12,7 @@ public interface IObservableAsync { /// Subscribes the specified asynchronous observer to receive notifications from the observable sequence. /// The observer that will receive asynchronous notifications. Cannot be null. - /// A cancellation token that can be used to cancel the subscription operation. + /// A token that cancels establishing the subscription. /// A handle whose disposal unsubscribes the observer. /// Implementations must tolerate several observers subscribed at once, and must release the /// subscription's resources when the returned handle is disposed. diff --git a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs index f2316798..89864d45 100644 --- a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs @@ -17,7 +17,7 @@ public interface IObserverAsync : IAsyncDisposable /// Reports a non-terminal error, leaving the sequence free to carry on. /// The exception to report. - /// A cancellation token that can be used to cancel the error handling operation. + /// A token that cancels the observer's handling of the error. /// A task that completes when the observer has handled the error. /// Unlike a faulted , this does not end the sequence; an implementation /// chooses whether to swallow the error or tear itself down. @@ -25,7 +25,7 @@ public interface IObserverAsync : IAsyncDisposable /// Delivers the next value in the sequence. /// The value to be processed. - /// A cancellation token that can be used to cancel the asynchronous operation. + /// A token that cancels the observer's handling of the value. /// A task that completes when the observer has consumed the value. ValueTask OnNextAsync(T value, CancellationToken cancellationToken); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs b/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs index 46361992..0840be17 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Mixins/SignalExtensions.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Async; -/// Provides extension methods for working with signals in a reactive programming context. +/// Adapts a signal to an observer, and projects a signal's value sequence without changing where values are published. public static class SignalExtensions { /// Observer-wrapping and value-mapping operators for a signal source. @@ -29,9 +29,8 @@ public IObserverAsync AsObserverAsync() return new SignalAsyncWitness(source); } - /// Creates a new signal that applies a transformation to the values of the source signal using the specified mapping function. - /// A function that takes an asynchronous observable of type T and returns a transformed asynchronous observable of - /// type T. This function defines how the values are mapped. + /// Creates a signal whose observers see applied to the source's value sequence. + /// Projects the source's value sequence into the sequence observers receive. /// A signal that publishes into but exposes the mapped sequence to its own /// subscribers. /// runs once, against the source's value sequence, rather than per @@ -48,8 +47,7 @@ public ISignalAsync MapValues(Func, IObservableAsync> /// A signal that applies a transformation to the observable values of the source signal. /// The type of elements processed by the signal. /// The source signal. - /// Transforms the source's value sequence - /// once during construction. + /// Transforms the source's value sequence once during construction. internal sealed class MappedSignal( ISignalAsync original, Func, IObservableAsync> mapper) : ISignalAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs index 56ce4820..5d9cfade 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs @@ -13,9 +13,7 @@ public static partial class SignalAsync { /// Creates a new asynchronous observable sequence using the specified subscription function. /// The type of the elements produced by the observable sequence. - /// A function that is invoked when an observer subscribes to the sequence. The function receives an asynchronous - /// observer and a cancellation token, and returns a task that yields a disposable resource representing the - /// subscription. + /// Wires up one observer and yields the handle that tears that wiring down. /// An observable sequence that runs for each observer. /// Thrown if is . /// The disposable returns owns the subscription's resources and must @@ -28,20 +26,18 @@ subscribeAsync is null /// Creates a new observable sequence that runs the specified asynchronous job as a background task. /// The type of elements produced by the observable sequence. - /// A delegate that defines the asynchronous job to execute. The delegate receives an observer to report results and - /// a cancellation token to observe cancellation requests. + /// Pushes results into the observer it is handed, stopping when its token is cancelled. /// An observable sequence that runs on a scheduled task per subscriber. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync CreateAsBackgroundJob( Func, CancellationToken, ValueTask> job) => new BackgroundJobSignal(job, false, null); - /// Creates a new observable sequence that runs the specified asynchronous job as a background task. + /// Creates a new observable sequence that runs the specified asynchronous job per subscriber, optionally on the subscribing thread. /// The type of elements produced by the observable sequence. - /// A delegate that defines the asynchronous job to execute. The delegate receives an observer to report results and - /// a cancellation token to observe cancellation requests. - /// true to start the job synchronously on the calling thread; otherwise, false to schedule it to run - /// asynchronously. + /// Pushes results into the observer it is handed, stopping when its token is cancelled. + /// to run the job on the subscribing thread, so subscribe + /// does not return until the job yields; to schedule it. /// An observable sequence that runs per subscriber. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync CreateAsBackgroundJob( @@ -51,9 +47,8 @@ public static IObservableAsync CreateAsBackgroundJob( /// Creates a new observable sequence that runs the specified asynchronous job as a background task using the provided task scheduler. /// The type of the elements produced by the observable sequence. - /// A delegate that defines the asynchronous job to execute. The delegate receives an observer to report results and - /// a cancellation token to observe cancellation requests. - /// The task scheduler that is used to schedule the background job. + /// Pushes results into the observer it is handed, stopping when its token is cancelled. + /// The scheduler each subscriber's job is queued to. /// An observable sequence that runs per subscriber on /// . [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs index 92afac0e..552f45b3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Interval.cs @@ -19,8 +19,7 @@ public static IObservableAsync Interval(TimeSpan period) => /// Creates an asynchronous observable sequence that emits a long integer value at each specified time interval. /// The time interval between emissions of values. Must be a positive duration. - /// An optional time provider used to control the timing of emissions. If null or set to TimeProvider.System, the - /// system clock is used. + /// The clock driving the ticks; takes the system clock. /// An observable sequence that emits an increasing count, starting at 1, on every tick until the /// subscription is disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs index d8206c9c..523005da 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Timer.cs @@ -36,8 +36,9 @@ public static IObservableAsync After(TimeSpan dueTime, TimeSpan period) } /// Emits monotonically increasing ticks at the specified period. - /// The interval between ticks. + /// The interval between ticks. Must be positive. /// An observable sequence of periodic ticks. + /// Thrown if is non-positive. public static IObservableAsync Every(TimeSpan period) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(period, TimeSpan.Zero); @@ -47,8 +48,9 @@ public static IObservableAsync Every(TimeSpan period) } /// Alias for . - /// The interval between ticks. + /// The interval between ticks. Must be positive. /// An observable sequence of periodic ticks. + /// Thrown if is non-positive. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Pulse(TimeSpan period) => Every(period); @@ -61,8 +63,8 @@ public static IObservableAsync Every(TimeSpan period) /// Creates an observable sequence that produces a single value (0) after the specified delay, then completes. /// The time span after which to produce the value. Must be non-negative. - /// An optional time provider for controlling timing. If null, - /// is used. + /// The clock driving the schedule; takes + /// . /// An observable sequence that produces a single value after the specified delay and then completes. /// Thrown if is negative. public static IObservableAsync Timer(TimeSpan dueTime, TimeProvider? timeProvider) @@ -85,8 +87,8 @@ public static IObservableAsync Timer(TimeSpan dueTime, TimeProvider? timeP /// Creates an observable sequence that produces a single value (0) after the specified delay, then continues to produce sequential values at each specified period. /// The initial delay before the first value is produced. Must be non-negative. /// The interval between subsequent values after the initial delay. Must be positive. - /// An optional time provider for controlling timing. If null, - /// is used. + /// The clock driving the schedule; takes + /// . /// An observable sequence that produces values starting after the initial delay and continuing /// at the specified period. /// Thrown if is negative diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs index 3f82bda5..cb240e00 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs @@ -51,7 +51,7 @@ public static partial class SignalAsyncExtensions /// The source sequence. extension(IObservableAsync source) { - /// Null-checks and returns the source unchanged, so generic code can convert without knowing whether it has a signal already. + /// Null-checks and returns the source unchanged, so generic code can convert without testing the source's type. /// The same sequence. /// is . [SuppressMessage( diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs index ff73bfff..dfd2ff09 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs @@ -22,7 +22,7 @@ public static partial class SignalAsyncExtensions /// A function to apply to each pair of elements. Cannot be null. /// An observable sequence whose elements are the result of pair-wise combining the source /// elements using the result selector. - /// Thrown if any argument is null. + /// , or is . /// The nth element of one source is paired with the nth element of the other, so an element waits until /// its partner arrives. The result completes when either source completes with no pending pair. public IObservableAsync Pair( diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs index 4f062322..02a2fb54 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Async; /// Fused filter / projection observables that back the parity-helper extension methods in . public static partial class SignalAsyncExtensions { - /// Emits each adjacent pair of source values, holding the prior value in a field rather than a closure. + /// Emits each adjacent (previous, current) pair of source values. /// The element type. /// The upstream observable. internal sealed class PairwiseSignal(IObservableAsync source) : IObservableAsync<(T Previous, T Current)> diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs index 9e7d4889..f2b9b19c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs @@ -147,7 +147,7 @@ private async ValueTask AwaitAndForwardAsync( } } - /// Schedules the value without canceling superseded delays; only the current value is emitted. + /// Debounces the source, dropping repeated values both before the delay and before each forward, so only the newest pending value is emitted. /// The element type. /// The upstream observable. /// The debounce window. @@ -434,7 +434,7 @@ private async ValueTask AwaitFullAsync(ValueTask actionTask, T value, Cancellati } } - /// Schedules the latest value, discarding superseded delay results. + /// Forwards a value immediately when the condition holds, and otherwise after the debounce window, discarding values a newer one supersedes. /// The element type. /// The upstream observable. /// The debounce window applied to bypass-false values. @@ -494,7 +494,7 @@ internal bool IsCurrentEmission(long id) } } - /// Starts a delay that supersedes the previously pending value. + /// Starts a delay for the value, superseding any pending one. /// The pending value. /// Cancellation for the delay. /// The delay and any downstream notification. @@ -587,7 +587,7 @@ private async Task DelayAndEmitAsync(T value, long id, CancellationToken cancell } } - /// Forwards every element of each enumerable the source emits, walking arrays and lists by index. + /// Forwards every element of each enumerable the source emits, in order. /// The flattened element type. /// The upstream observable of snapshots. internal sealed class ForEachEnumerableSignal(IObservableAsync> source) : IObservableAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs index ad0c0a3b..89957229 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs @@ -20,6 +20,8 @@ public static partial class SignalAsyncExtensions { /// Emits when the latest value from every source sequence is . /// A sequence of aggregate boolean states. + /// An empty sequence emits a single ; otherwise nothing is + /// emitted until every source has produced a value. public IObservableAsync CombineLatestValuesAreAllFalse() { ArgumentExceptionHelper.ThrowIfNull(sources); @@ -43,6 +45,8 @@ public IObservableAsync CombineLatestValuesAreAllFalse() /// Emits when the latest value from every source sequence is . /// A sequence of aggregate boolean states. + /// An empty sequence emits a single ; otherwise nothing is + /// emitted until every source has produced a value. public IObservableAsync CombineLatestValuesAreAllTrue() { ArgumentExceptionHelper.ThrowIfNull(sources); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs index 8cdfe496..2fdb4b5f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ReduceAsync.cs @@ -20,7 +20,7 @@ public static partial class SignalAsyncExtensions /// An asynchronous accumulator function to invoke on each element. Receives the /// current accumulated value, the current element, and a cancellation token. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask AggregateAsync( TAcc seed, @@ -34,7 +34,7 @@ public ValueTask AggregateAsync( /// current accumulated value, the current element, and a cancellation token. /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask AggregateAsync( TAcc seed, @@ -48,7 +48,7 @@ public ValueTask AggregateAsync( /// An accumulator function to invoke on each element. Receives the current /// accumulated value and the current element. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask AggregateAsync( TAcc seed, @@ -62,7 +62,7 @@ public ValueTask AggregateAsync( /// accumulated value and the current element. /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask AggregateAsync( TAcc seed, @@ -77,8 +77,7 @@ public ValueTask AggregateAsync( /// An accumulator function to invoke on each element. /// A function to transform the final accumulated value into the result value. /// A task representing the asynchronous operation, containing the transformed result. - /// Thrown if or - /// is null. + /// or is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask AggregateAsync( TAcc seed, @@ -94,8 +93,7 @@ public ValueTask AggregateAsync( /// A function to transform the final accumulated value into the result value. /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the transformed result. - /// Thrown if or - /// is null. + /// or is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask AggregateAsync( TAcc seed, @@ -110,7 +108,7 @@ public ValueTask AggregateAsync( /// An asynchronous accumulator function to invoke on each element. Receives the /// current accumulated value, the current element, and a cancellation token. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ReduceAsync( TAcc seed, @@ -124,7 +122,7 @@ public ValueTask ReduceAsync( /// current accumulated value, the current element, and a cancellation token. /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . public async ValueTask ReduceAsync( TAcc seed, Func> accumulator, @@ -145,7 +143,7 @@ public async ValueTask ReduceAsync( /// An accumulator function to invoke on each element. Receives the current /// accumulated value and the current element. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ReduceAsync( TAccumulate seed, @@ -159,7 +157,7 @@ public ValueTask ReduceAsync( /// accumulated value and the current element. /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the final accumulated value. - /// Thrown if is null. + /// is . public async ValueTask ReduceAsync( TAcc seed, Func accumulator, @@ -181,8 +179,7 @@ public async ValueTask ReduceAsync( /// An accumulator function to invoke on each element. /// A function to transform the final accumulated value into the result value. /// A task representing the asynchronous operation, containing the transformed result. - /// Thrown if or - /// is null. + /// or is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ReduceAsync( TAcc seed, @@ -198,8 +195,7 @@ public ValueTask ReduceAsync( /// A function to transform the final accumulated value into the result value. /// The token that cancels the operation. /// A task representing the asynchronous operation, containing the transformed result. - /// Thrown if or - /// is null. + /// or is . public async ValueTask ReduceAsync( TAcc seed, Func accumulator, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Bind.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Bind.Rx.cs index 3c283979..a53baec0 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Bind.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Bind.Rx.cs @@ -16,6 +16,7 @@ public static partial class SignalAsyncExtensions /// The result element type. /// The projection producing an inner sequence for each value. /// An observable sequence of merged inner values. + /// is . public IObservableAsync Bind(Func> selector) { ArgumentExceptionHelper.ThrowIfNull(selector); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs index 5060d051..9de68bc6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.DistinctUntilChanged.Rx.cs @@ -44,6 +44,7 @@ public IObservableAsync DistinctUntilChanged(IEqualityComparer equalityCom /// A function that extracts the comparison key from each element in the source sequence. /// An observable sequence that contains only the elements from the source sequence that are not consecutive /// duplicates according to the specified key. + /// is . public IObservableAsync DistinctUntilChangedBy(Func keySelector) { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Rescue.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Rescue.Rx.cs index 8eaa14c3..95c0c634 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Rescue.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Rescue.Rx.cs @@ -15,6 +15,7 @@ public static partial class SignalAsyncExtensions /// Recovers from a terminal failure with a replacement sequence. /// The handler that produces a replacement sequence from the error. /// An observable sequence that recovers from failures. + /// is . public IObservableAsync Rescue(Func> handler) { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs index 88f35d07..b89dd159 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Scan.Rx.cs @@ -17,6 +17,7 @@ public static partial class SignalAsyncExtensions /// The initial accumulator value. /// An asynchronous accumulator function to be invoked on each element. /// An observable sequence containing the accumulated values produced after each element is processed. + /// is . public IObservableAsync Scan( TAcc seed, Func> accumulator) @@ -31,6 +32,7 @@ public IObservableAsync Scan( /// The initial accumulator value. /// An accumulator function to be invoked on each element. /// An observable sequence containing the accumulated values produced after each element is processed. + /// is . public IObservableAsync Scan(TAcc seed, Func accumulator) { ArgumentExceptionHelper.ThrowIfNull(accumulator); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs index 9b1fc010..cd19ff95 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.SelectMany.Rx.cs @@ -16,6 +16,7 @@ public static partial class SignalAsyncExtensions /// The type of the elements in the projected inner sequences. /// A transform function to apply to each element. /// An observable sequence whose elements are the merged projection results. + /// is . public IObservableAsync SelectMany(Func> selector) { ArgumentExceptionHelper.ThrowIfNull(selector); @@ -30,6 +31,7 @@ public IObservableAsync SelectMany(FuncThe type of the elements in the projected inner sequences. /// An asynchronous transform function to apply to each element. /// An observable sequence whose elements are the merged projection results. + /// is . public IObservableAsync SelectMany( Func>> selector) { @@ -52,8 +54,7 @@ public IObservableAsync SelectMany( /// An observable sequence whose elements are the result of invoking the one-to-many transform /// function on each element of the source sequence, and then mapping each pair of source and collection /// element through the result selector. - /// Thrown if or - /// is null. + /// or is . public IObservableAsync SelectMany( Func> collectionSelector, Func resultSelector) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs index 27309ffb..e25370cc 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs @@ -53,7 +53,7 @@ public IObservableAsync Timeout(TimeSpan dueTime, TimeProvider? timeProvider) /// An observable sequence that mirrors the source, switching to the fallback sequence /// if any inter-element interval exceeds the specified interval. /// Thrown if is negative or zero. - /// Thrown if is null. + /// is . public IObservableAsync Timeout(TimeSpan dueTime, IObservableAsync fallback) { ArgumentExceptionHelper.ThrowIfNull(fallback); @@ -73,7 +73,7 @@ public IObservableAsync Timeout(TimeSpan dueTime, IObservableAsync fallbac /// An observable sequence that mirrors the source, switching to the fallback sequence /// if any inter-element interval exceeds the specified interval. /// Thrown if is negative or zero. - /// Thrown if is null. + /// is . public IObservableAsync Timeout( TimeSpan dueTime, IObservableAsync fallback, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Zip.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Zip.Rx.cs index 6e6f749a..48619d5d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Zip.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Zip.Rx.cs @@ -18,6 +18,7 @@ public static partial class SignalAsyncExtensions /// The second observable sequence. /// A function to apply to each pair of elements. /// An observable sequence whose elements are the pair-wise combination of source elements. + /// , or is . public IObservableAsync Zip( IObservableAsync second, Func resultSelector) @@ -33,6 +34,7 @@ public IObservableAsync Zip( /// The type of elements in the second source sequence. /// The second observable sequence. /// An observable sequence of tuples pairing elements from each source. + /// or is . public IObservableAsync<(T1 First, T2 Second)> Zip(IObservableAsync second) { ArgumentExceptionHelper.ThrowIfNull(src1); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs index 4eed6c62..cadf1f93 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleAsync.cs @@ -14,40 +14,40 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// Asynchronously returns the single element of a sequence that satisfies a specified condition, or throws an exception if more than one such element exists. + /// Asynchronously returns the one element of the sequence that satisfies the specified condition. /// A function to test each element for a condition. The method returns the element for which this predicate /// returns . /// A task that represents the asynchronous operation. The task result contains the single element that matches /// the predicate. - /// Both no match and more than one match throw. + /// No element satisfies , or more than one does. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync(Func predicate) => source.SingleAsync(predicate, CancellationToken.None); - /// Asynchronously returns the single element of a sequence that satisfies a specified condition, or throws an exception if more than one such element exists. + /// Asynchronously returns the one element of the sequence that satisfies the specified condition. /// A function to test each element for a condition. The method returns the element for which this predicate /// returns . /// The token that cancels the operation. /// A task that represents the asynchronous operation. The task result contains the single element that matches /// the predicate. - /// Both no match and more than one match throw. + /// No element satisfies , or more than one does. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync(Func predicate, CancellationToken cancellationToken) => SingleCoreAsync(source, predicate, cancellationToken); - /// Asynchronously returns the single element of the sequence, and throws an exception if the sequence does not contain exactly one element. + /// Asynchronously returns the one element of a sequence that contains exactly one element. /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence. - /// Both an empty sequence and a sequence of more than one element throw. + /// The source sequence is empty or contains more than one element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync() => source.SingleAsync(CancellationToken.None); - /// Asynchronously returns the single element of the sequence, and throws an exception if the sequence does not contain exactly one element. + /// Asynchronously returns the one element of a sequence that contains exactly one element. /// The token that cancels the operation. /// A task that represents the asynchronous operation. The task result contains the single element of the /// sequence. - /// Both an empty sequence and a sequence of more than one element throw. + /// The source sequence is empty or contains more than one element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleAsync(CancellationToken cancellationToken) => SingleCoreAsync(source, null, cancellationToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs index d6bf893a..7c21baed 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SingleOrDefaultAsync.cs @@ -14,35 +14,29 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Asynchronously returns the only element of a sequence that satisfies a specified condition, or a default - /// value if no such element exists; this operation throws if more than one matching element is found. - /// + /// Asynchronously returns the only element of the sequence that satisfies a condition, or when no element does. /// A function to test each element for a condition. The method returns the element for which this predicate /// returns . /// The value to return if no element in the sequence satisfies the condition specified by . - /// A value task that represents the asynchronous operation. The result contains the single element that matches - /// the predicate, the specified default value if no such element is found, or throws an exception if more than - /// one matching element exists. + /// A value task whose result is the single matching element, or when no + /// element matches. + /// More than one element satisfies . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync( Func predicate, T? defaultValue) => source.SingleOrDefaultAsync(predicate, defaultValue, CancellationToken.None); - /// - /// Asynchronously returns the only element of a sequence that satisfies a specified condition, or a default - /// value if no such element exists; this operation throws if more than one matching element is found. - /// + /// Asynchronously returns the only element of the sequence that satisfies a condition, or when no element does. /// A function to test each element for a condition. The method returns the element for which this predicate /// returns . /// The value to return if no element in the sequence satisfies the condition specified by . /// The token that cancels the operation. - /// A value task that represents the asynchronous operation. The result contains the single element that matches - /// the predicate, the specified default value if no such element is found, or throws an exception if more than - /// one matching element exists. + /// A value task whose result is the single matching element, or when no + /// element matches. + /// More than one element satisfies . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync( Func predicate, @@ -50,42 +44,38 @@ public static partial class SignalAsyncExtensions CancellationToken cancellationToken) => SingleOrDefaultCoreAsync(source, predicate, defaultValue, cancellationToken); - /// Asynchronously returns the only element of a sequence, or a default value if the sequence is empty; this operation throws an exception if more than one element is found. + /// Asynchronously returns the only element of the sequence, or the default of when the sequence is empty. /// A value task that represents the asynchronous operation. The task result contains the single element of the /// sequence, or the default value of if the sequence is empty. + /// The source sequence contains more than one element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync() => source.SingleOrDefaultAsync(default, CancellationToken.None); - /// Asynchronously returns the only element of a sequence, or a default value if the sequence is empty; this operation throws an exception if more than one element is found. + /// Asynchronously returns the only element of the sequence, or the default of when the sequence is empty. /// The token that cancels the operation. /// A value task that represents the asynchronous operation. The task result contains the single element of the /// sequence, or the default value of if the sequence is empty. + /// The source sequence contains more than one element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync(CancellationToken cancellationToken) => source.SingleOrDefaultAsync(default, cancellationToken); - /// - /// Asynchronously returns the single element of the sequence, or a specified default value if the sequence is - /// empty. Throws an exception if the sequence contains more than one element. - /// + /// Asynchronously returns the single element of the sequence, or when the sequence is empty. /// The value to return if the sequence contains no elements. - /// A task that represents the asynchronous operation. The task result contains the single element of the - /// sequence, the specified default value if the sequence is empty, or throws if more than one element is - /// present. + /// A value task whose result is the single element of the sequence, or when + /// the sequence is empty. + /// The source sequence contains more than one element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync(T? defaultValue) => source.SingleOrDefaultAsync(defaultValue, CancellationToken.None); - /// - /// Asynchronously returns the single element of the sequence, or a specified default value if the sequence is - /// empty. Throws an exception if the sequence contains more than one element. - /// + /// Asynchronously returns the single element of the sequence, or when the sequence is empty. /// The value to return if the sequence contains no elements. /// The token that cancels the operation. - /// A task that represents the asynchronous operation. The task result contains the single element of the - /// sequence, the specified default value if the sequence is empty, or throws if more than one element is - /// present. + /// A value task whose result is the single element of the sequence, or when + /// the sequence is empty. + /// The source sequence contains more than one element. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SingleOrDefaultAsync(T? defaultValue, CancellationToken cancellationToken) => SingleOrDefaultCoreAsync(source, null, defaultValue, cancellationToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs index f800c91d..11aae0d0 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Skip.cs @@ -16,7 +16,7 @@ public static partial class SignalAsyncExtensions /// The number of elements to skip. Must be greater than or equal to 0. /// An observable sequence that contains the elements of the source sequence after the specified number of /// elements have been skipped. If the count is 0, the original sequence is returned. - /// Thrown if count is less than 0. + /// is less than zero. public IObservableAsync Skip(int count) { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs index 81f99d86..b5950391 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SkipWhile.cs @@ -17,7 +17,7 @@ public static partial class SignalAsyncExtensions /// and a cancellation token. /// An observable sequence that skips elements while the predicate returns true and emits /// all subsequent elements. - /// Thrown if is null. + /// is . public IObservableAsync SkipWhile(Func> predicate) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -30,7 +30,7 @@ public IObservableAsync SkipWhile(Func> /// A function to test each element for a condition. /// An observable sequence that skips elements while the predicate returns true and emits /// all subsequent elements. - /// Thrown if is null. + /// is . public IObservableAsync SkipWhile(Func predicate) { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs index b49f7a58..252a0faf 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs @@ -24,7 +24,7 @@ public static partial class SignalAsyncExtensions /// The token that cancels the subscription and any in-progress callbacks. /// A value task that represents the asynchronous operation. The result is an /// that can be disposed to unsubscribe from the data source. - /// Thrown if the underlying data source is null. + /// The source sequence or is . /// Callbacks may be invoked concurrently, so the supplied delegates must be thread-safe. public ValueTask SubscribeAsync( Func onNextAsync, @@ -44,7 +44,7 @@ public ValueTask SubscribeAsync( /// An optional delegate that is invoked asynchronously if an error occurs during data processing. /// A value task that represents the asynchronous operation. The result is an /// that can be disposed to unsubscribe from the data source. - /// Thrown if the underlying data source is null. + /// The source sequence or is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SubscribeAsync( Func onNextAsync, @@ -57,7 +57,7 @@ public ValueTask SubscribeAsync( /// An optional delegate that is invoked asynchronously when the data source completes successfully. /// A value task that represents the asynchronous operation. The result is an /// that can be disposed to unsubscribe from the data source. - /// Thrown if the underlying data source is null. + /// The source sequence or is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SubscribeAsync( Func onNextAsync, @@ -70,7 +70,7 @@ public ValueTask SubscribeAsync( /// The token that cancels the subscription. /// A value task that represents the asynchronous subscription operation. The result contains an that can be disposed to unsubscribe from the sequence. - /// Thrown if is null. + /// is . public ValueTask SubscribeAsync( Action onNext, CancellationToken cancellationToken) @@ -90,7 +90,7 @@ public ValueTask SubscribeAsync( /// An action to invoke for each element in the sequence. Cannot be null. /// A value task that represents the asynchronous subscription operation. The result contains an that can be disposed to unsubscribe from the sequence. - /// Thrown if is null. + /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask SubscribeAsync(Action onNext) => source.SubscribeAsync(onNext, CancellationToken.None); @@ -104,7 +104,7 @@ public ValueTask SubscribeAsync(Action onNext) => /// The token that cancels the subscription. /// A value task that represents the asynchronous subscription operation. The result is an that can be disposed to unsubscribe from the sequence. - /// Thrown if is null, or if the underlying source is null. + /// or the source sequence is . public ValueTask SubscribeAsync( Action onNext, Action? onErrorResume, @@ -160,7 +160,7 @@ public ValueTask SubscribeAsync(FuncThe token that cancels the subscription. /// A ValueTask that represents the asynchronous subscription operation. The result is an IAsyncDisposable that /// can be disposed to unsubscribe from the sequence. - /// Thrown if the underlying source is null. + /// The source sequence or is . public ValueTask SubscribeAsync( Func onNextAsync, CancellationToken cancellationToken) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs index 3c26f71e..50e41393 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs @@ -55,6 +55,7 @@ public IObservableAsync TakeUntil(IObservableAsync other, Tak /// The observable sequence whose first emission or completion terminates the result. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if either the source sequence or the other observable is null. public IObservableAsync TakeUntil( IObservableAsync other, CancellationToken cancellationToken) @@ -75,6 +76,7 @@ public IObservableAsync TakeUntil( /// Options controlling the take-until behavior, or null for defaults. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if either the source sequence or the other observable is null. public IObservableAsync TakeUntil( IObservableAsync other, TakeUntilOptions? options, @@ -123,6 +125,7 @@ public IObservableAsync TakeUntil(Task task, TakeUntilOptions? options) /// The task whose completion terminates the result. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if the source observable is null. public IObservableAsync TakeUntil(Task task, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -142,6 +145,7 @@ public IObservableAsync TakeUntil(Task task, CancellationToken cancellationTo /// Options controlling the take-until behavior, or null for defaults. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if the source observable is null. public IObservableAsync TakeUntil( Task task, TakeUntilOptions? options, @@ -186,6 +190,7 @@ public IObservableAsync TakeUntil(Func predicate) /// A predicate evaluated for each element; first true terminates the sequence. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if is null. public IObservableAsync TakeUntil(Func predicate, CancellationToken cancellationToken) { ArgumentExceptionHelper.ThrowIfNull(predicate); @@ -216,6 +221,7 @@ public IObservableAsync TakeUntil(Func> /// An async predicate evaluated for each element; first true terminates the sequence. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if is null. public IObservableAsync TakeUntil( Func> asyncPredicate, CancellationToken cancellationToken) @@ -261,6 +267,7 @@ public IObservableAsync TakeUntil( /// A delegate that provides the completion stop signal. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if is null. public IObservableAsync TakeUntil( CompletionSignalDelegate stopSignal, CancellationToken cancellationToken) @@ -279,6 +286,7 @@ public IObservableAsync TakeUntil( /// Options controlling the take-until behavior, or null for defaults. /// A cancellation token that also terminates the result when cancelled. /// An observable sequence that completes on the first of the two signals. + /// Thrown if is null. public IObservableAsync TakeUntil( CompletionSignalDelegate stopSignal, TakeUntilOptions? options, @@ -479,7 +487,7 @@ internal async ValueTask SubscribeSourcesAsync(CancellationToken cancellationTok internal void LinkExternalCancellation(CancellationToken external) => _lifecycle.LinkExternalCancellation(external); - /// Completes on the notifying thread when downstream completion is synchronous. + /// Hands the stop callback to the delegate and releases a registration that arrives after the callback has fired. internal void AwaitStopThenComplete() { Volatile.Write(ref _stopRegistration, _parent._stopSignal(Stop)); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs index 69d099ba..5e3a2547 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeWhile.cs @@ -17,7 +17,7 @@ public static partial class SignalAsyncExtensions /// and a cancellation token. /// An observable sequence that contains elements from the source sequence that satisfy the /// condition, completing as soon as the predicate returns false. - /// Thrown if is null. + /// is . public IObservableAsync TakeWhile(Func> predicate) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -30,7 +30,7 @@ public IObservableAsync TakeWhile(Func> /// A function to test each element for a condition. /// An observable sequence that contains elements from the source sequence that satisfy the /// condition, completing as soon as the predicate returns false. - /// Thrown if is null. + /// is . public IObservableAsync TakeWhile(Func predicate) { ArgumentExceptionHelper.ThrowIfNull(source); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs index 86576174..3f40e1b4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs @@ -5,7 +5,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides Throttle (debounce) extension methods for asynchronous observable sequences. -/// Only the latest value is emitted after the quiet period; each source value restarts that period. public static partial class SignalAsyncExtensions { /// Throttle (debounce) operators for an observable source sequence. @@ -19,6 +18,7 @@ public static partial class SignalAsyncExtensions /// An observable sequence containing only those elements that are not followed by another /// element within the specified due time. /// Thrown if is negative. + /// Each source element restarts the quiet period, so a continuous burst forwards only its final element. public IObservableAsync Throttle(TimeSpan dueTime) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(dueTime, TimeSpan.Zero); @@ -34,6 +34,7 @@ public IObservableAsync Throttle(TimeSpan dueTime) /// An observable sequence containing only those elements that are not followed by another /// element within the specified due time. /// Thrown if is negative. + /// Each source element restarts the quiet period, so a continuous burst forwards only its final element. public IObservableAsync Throttle(TimeSpan dueTime, TimeProvider? timeProvider) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(dueTime, TimeSpan.Zero); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs index 25bee995..f901a5d8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs @@ -21,13 +21,8 @@ public static partial class SignalAsyncExtensions /// the buffering and backpressure behavior between the asynchronous observable and the asynchronous enumerable. /// An asynchronous enumerable sequence that yields elements from the asynchronous observable. The /// enumeration completes when the source observable completes, or an unhandled error occurs. - /// Thrown when or - /// is null. - /// - /// The resulting asynchronous enumerable sequence reflects the items and completion behavior of the source - /// asynchronous observable. The buffering behavior is determined by the channel created by the provided - /// . - /// + /// or is . + /// The channel returned by determines buffering; a source error completes the enumeration with that error. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IAsyncEnumerable ToAsyncEnumerable( Func> channelFactory) => @@ -41,7 +36,7 @@ public IAsyncEnumerable ToAsyncEnumerable( /// sequence completes with the error. /// An asynchronous enumerable sequence that yields the elements produced by the observable sequence. The /// enumeration completes when the observable completes or an unhandled error occurs. - /// Thrown if or is null. + /// or is . /// The channel factory controls buffering. The optional error callback handles source errors; without it, errors propagate to the enumerator. public IAsyncEnumerable ToAsyncEnumerable( Func> channelFactory, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs index 13b96143..17b148f3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ToDictionaryAsync.cs @@ -22,7 +22,8 @@ public static partial class SignalAsyncExtensions /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains a dictionary mapping keys to /// elements from the sequence. - /// Thrown if the keySelector parameter is null. + /// is . + /// Two elements of the source sequence produce the same key. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask> ToDictionaryAsync( Func keySelector, @@ -36,7 +37,8 @@ public ValueTask> ToDictionaryAsync( /// A function to extract a key from each element in the sequence. Cannot be null. /// A task that represents the asynchronous operation. The task result contains a dictionary mapping keys to /// elements from the sequence. - /// Thrown if the keySelector parameter is null. + /// is . + /// Two elements of the source sequence produce the same key. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask> ToDictionaryAsync(Func keySelector) where TKey : notnull => @@ -52,9 +54,8 @@ public ValueTask> ToDictionaryAsync(Func keyS /// A cancellation token that can be used to cancel the asynchronous operation. /// A task that represents the asynchronous operation. The task result contains a dictionary mapping keys to /// values as defined by the selector functions. - /// Thrown if or is null. - /// If multiple elements produce the same key, an exception may be thrown. The operation - /// is performed asynchronously and can be cancelled using the provided cancellation token. + /// or is . + /// Two elements of the source sequence produce the same key. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask> ToDictionaryAsync( Func keySelector, @@ -74,7 +75,8 @@ public ValueTask> ToDictionaryAsync( /// A function to map each element in the sequence to a value in the resulting dictionary. /// A task that represents the asynchronous operation. The task result contains a dictionary mapping keys to /// values as defined by the selector functions. - /// Thrown if or is null. + /// or is . + /// Two elements of the source sequence produce the same key. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask> ToDictionaryAsync( Func keySelector, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs index ed06637c..248bcf57 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs @@ -33,9 +33,8 @@ public IObservableAsync Unique() /// An observable sequence that contains only distinct consecutive elements from the source sequence, as /// determined by the specified equality comparer. /// Thrown if is . - /// Use this method to suppress consecutive duplicate elements in the sequence. Only - /// elements that differ from their immediate predecessor, according to the provided comparer, are emitted to - /// observers. + /// Only elements that differ from their immediate predecessor, as judged by , are emitted; non-consecutive duplicates are not suppressed. public IObservableAsync Unique(IEqualityComparer equalityComparer) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -82,7 +81,7 @@ public IObservableAsync UniqueBy( } } - /// Single-observer-layer DistinctUntilChanged. Replaces the previous \c Create + async-lambda + closure pattern; per-subscription state lives in observer fields. + /// Drops each value that the comparer judges equal to the most-recently-forwarded one. /// The element type. /// The upstream observable. /// The equality comparer used to detect duplicates. @@ -114,7 +113,7 @@ internal sealed class UniqueWitness( IEqualityComparer comparer, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// The previously-forwarded value; valid only when is set. + /// The most-recently-forwarded value; valid only when is set. private T? _previous; /// Latches to after the first emission has been forwarded. @@ -182,7 +181,7 @@ internal sealed class UniqueByWitness( IEqualityComparer comparer, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// The previously-forwarded key; valid only when is set. + /// The most-recently-forwarded key; valid only when is set. private TKey? _previousKey; /// Latches to after the first emission has been forwarded. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs index 56e3b6ff..bfd8d880 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Using.cs @@ -39,9 +39,7 @@ public static IObservableAsync Use( [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "Using is the System.Reactive name for Use. Both operators intentionally build the same signal directly " - + "rather than one forwarding to the other, so the Rx-named alias costs nothing at the call site.")] + Justification = "Using is the System.Reactive name for Use and builds the same signal.")] public static IObservableAsync Using( Func> resourceFactory, Func> signalFactory) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/WaitCompletionAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/WaitCompletionAsync.cs index f32ef4c0..d4942fd8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/WaitCompletionAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/WaitCompletionAsync.cs @@ -16,8 +16,8 @@ public static partial class SignalAsyncExtensions { /// Asynchronously waits for the observable sequence to complete without retrieving any values. /// A ValueTask that represents the asynchronous wait operation. - /// This method subscribes to the observable sequence and completes when the sequence signals - /// completion or when the operation is canceled. Any values produced by the sequence are ignored. + /// The source is subscribed once and its values are ignored; the returned task completes when the + /// sequence completes and faults with the error when it fails. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask WaitCompletionAsync() => source.WaitCompletionAsync(CancellationToken.None); @@ -25,8 +25,8 @@ public ValueTask WaitCompletionAsync() => /// Asynchronously waits for the observable sequence to complete without retrieving any values. /// A cancellation token that can be used to cancel the wait operation. /// A ValueTask that represents the asynchronous wait operation. - /// This method subscribes to the observable sequence and completes when the sequence signals - /// completion or when the operation is canceled. Any values produced by the sequence are ignored. + /// The source is subscribed once and its values are ignored; the returned task completes when the + /// sequence completes and faults with the error when it fails. public async ValueTask WaitCompletionAsync( CancellationToken cancellationToken) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs index 4188c29c..8bb55f5f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/Concurrent.cs @@ -11,14 +11,12 @@ namespace ReactiveUI.Primitives.Async.Signals; /// All observers start concurrently; the returned task waits for every observer. Empty collections complete synchronously, and failures follow Task.WhenAll semantics. public static class Concurrent { - /// Forwards the specified value to all observers concurrently by invoking their OnNextAsync methods. + /// Forwards a value to every observer without waiting for any one of them to finish first. /// The type of the value to forward to the observers. - /// A read-only list of observers that will receive the value. Cannot be null. + /// The observers to notify. /// The value to forward to each observer. - /// A cancellation token that can be used to cancel the forwarding operation. - /// A ValueTask that represents the asynchronous operation of forwarding the value to all observers. The task - /// completes when all observers have processed the value. - /// Empty collections complete synchronously. Multiple observers follow Task.WhenAll failure semantics. + /// A token passed to each observer's notification. + /// A task that completes once every observer has processed the value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ValueTask ForwardOnNextConcurrently( ImmutableArray> observers, @@ -57,13 +55,12 @@ public static ValueTask ForwardOnNextConcurrently( return default; } - /// Forwards an error notification to all specified asynchronous observers concurrently, allowing each observer to handle the error and resume as appropriate. + /// Forwards a non-terminal error to every observer without waiting for any one of them to finish first. /// The type of the elements observed by the observers. - /// A read-only list of asynchronous observers to which the error notification will be forwarded. Cannot be null. + /// The observers to notify. /// The exception representing the error to forward to each observer. Cannot be null. - /// A cancellation token that can be used to cancel the forwarding operation. - /// A ValueTask that represents the asynchronous operation of forwarding the error to all observers. The task - /// completes when all observers have processed the error notification. + /// A token passed to each observer's notification. + /// A task that completes once every observer has processed the error. /// Cancellation is forwarded to each observer; it does not prevent other observers from being called. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ValueTask ForwardOnErrorResumeConcurrently( @@ -103,14 +100,11 @@ public static ValueTask ForwardOnErrorResumeConcurrently( return default; } - /// Invokes the OnCompletedAsync method on each observer in the collection concurrently, forwarding the specified result to all observers. + /// Forwards the terminal result to every observer without waiting for any one of them to finish first. /// The type of the elements observed by the observers. - /// A read-only list of observers to which the completion notification will be forwarded. Cannot be null. - /// The result to pass to each observer's OnCompletedAsync method. - /// A ValueTask that represents the asynchronous operation of notifying all observers. The task completes when all - /// observers have finished processing the completion notification. If the observers list is empty, a default - /// ValueTask is returned. - /// Empty collections complete synchronously. Multiple observers follow Task.WhenAll failure semantics. + /// The observers to notify. + /// The terminal result to forward to each observer. + /// A task that completes once every observer has processed the completion. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ValueTask ForwardOnCompletedConcurrently( ImmutableArray> observers, diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs index a0ab9527..29fd7e11 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs @@ -12,7 +12,7 @@ internal sealed class ReplayLatestSignalAsyncStateObserverLease( ReplayLatestSignalAsyncState state, IObserverAsync observer) : IAsyncDisposable { - /// Indicates whether the lease has already removed its observer. + /// Latch raised by the first disposer, so the observer is removed once. private int _disposed; /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs index af676fe6..dc188286 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncState{T}.cs @@ -11,11 +11,11 @@ namespace ReactiveUI.Primitives.Async.Signals; /// The observed value type. internal sealed class ReplayLatestSignalAsyncState : IDisposable { - /// The asynchronous gate used to synchronize mutable state. + /// Serializes every mutation of this state, including the replay emitted during subscribe. [SuppressMessage( "Style", "SST1401:Field should be private", - Justification = "Gate fields are intentionally direct readonly state for helper access.")] + Justification = "The state helpers enter this gate directly.")] internal readonly AsyncSerialGate Gate = new(); /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs index 35870106..480816a3 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SerialBroadcastHelpers.cs @@ -15,7 +15,7 @@ internal static class SerialBroadcastHelpers /// The element type. /// The current observer snapshot. /// The value being broadcast. - /// A cancellation token that can be used to cancel the notification operation. + /// A token passed to each observer's notification. /// A task that represents the asynchronous notification operation. internal static ValueTask BroadcastOnNextAsync( ImmutableArray> observers, @@ -29,7 +29,7 @@ internal static ValueTask BroadcastOnNextAsync( /// The element type. /// The current observer snapshot. /// The value being broadcast. - /// A cancellation token that can be used to cancel the notification operation. + /// A token passed to each observer's notification. /// A task that represents the asynchronous notification operation. internal static ValueTask BroadcastOnNextAsyncMulti( ImmutableArray> observers, @@ -54,7 +54,7 @@ internal static ValueTask BroadcastOnNextAsyncMulti( /// The element type. /// The current observer snapshot. /// The error being broadcast. - /// A cancellation token that can be used to cancel the notification operation. + /// A token passed to each observer's notification. /// A task that represents the asynchronous notification operation. internal static ValueTask BroadcastOnErrorResumeAsync( ImmutableArray> observers, @@ -105,7 +105,7 @@ internal static ValueTask BroadcastOnCompletedAsync( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", Justification = - "Every caller returns early unless IsCompletedSuccessfully, so this only ever consumes a task that is already done. The guard is one frame up, which the rule does not follow.")] + "Every caller checks IsCompletedSuccessfully and returns early otherwise, so the awaiter only ever observes a completed task.")] private static void ConsumeCompleted(ValueTask pending) => pending.GetAwaiter().GetResult(); diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncStateObserverLease{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncStateObserverLease{T}.cs index bb86d9c2..671d979c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncStateObserverLease{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncStateObserverLease{T}.cs @@ -12,7 +12,7 @@ internal sealed class SignalAsyncStateObserverLease( SignalAsyncState state, IObserverAsync observer) : IAsyncDisposable { - /// Indicates whether the lease has already removed its observer. + /// Latch raised by the first disposer, so the observer is removed once. private int _disposed; /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncState{T}.cs index eccfef2b..ad5a9c1c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/SignalAsyncState{T}.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// The observed value type. internal sealed class SignalAsyncState { - /// The lock used to synchronize mutable state. + /// Guards the observer list and the completion result against concurrent mutation. private readonly Lock _gate = new(); /// Gets or sets the currently subscribed observers. @@ -19,9 +19,9 @@ internal sealed class SignalAsyncState /// Gets or sets the completion result, or null if the signal has not completed. internal Result? Result { get; set; } - /// Gets a stable observer snapshot when the signal is still active. + /// Gets a stable observer snapshot when the signal has not completed. /// Receives the observers to notify when the signal is active. - /// true if observers should be notified; otherwise, false. + /// when observers should be notified; otherwise, . internal bool TryGetObservers(out ImmutableArray> observers) { lock (_gate) @@ -40,7 +40,7 @@ internal bool TryGetObservers(out ImmutableArray> observers) /// Marks the signal as completed and returns the observers to notify. /// The completion result to store. /// Receives the observers subscribed at completion time. - /// true if this call completed the signal; otherwise, false. + /// when this call completed the signal; otherwise, . internal bool TryComplete(Result result, out ImmutableArray> observers) { lock (_gate) @@ -58,9 +58,9 @@ internal bool TryComplete(Result result, out ImmutableArray> o } } - /// Adds an observer unless the signal has already completed. + /// Adds an observer unless the signal has completed. /// The observer to subscribe. - /// The existing completion result when the signal is already completed; otherwise, null. + /// The stored completion result when the signal has completed; otherwise, null. internal Result? Subscribe(IObserverAsync observer) { lock (_gate) diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs index 1a6a1de9..9777bde9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs @@ -12,7 +12,7 @@ internal sealed class StatelessReplayLatestSignalAsyncStateObserverLease( StatelessReplayLatestSignalAsyncState state, IObserverAsync observer) : IAsyncDisposable { - /// Indicates whether the lease has already removed its observer. + /// Latch raised by the first disposer, so the observer is removed once. private int _disposed; /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs index 2d64e4d1..f775c8c5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncState{T}.cs @@ -11,11 +11,11 @@ namespace ReactiveUI.Primitives.Async.Signals; /// The observed value type. internal sealed class StatelessReplayLatestSignalAsyncState : IDisposable { - /// The asynchronous gate used to synchronize mutable state. + /// Serializes every mutation of this state, including the replay emitted during subscribe. [SuppressMessage( "Style", "SST1401:Field should be private", - Justification = "Gate fields are intentionally direct readonly state for helper access.")] + Justification = "The state helpers enter this gate directly.")] internal readonly AsyncSerialGate Gate = new(); /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessSignalAsyncState{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessSignalAsyncState{T}.cs index 5514d3c5..eacf27ff 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessSignalAsyncState{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessSignalAsyncState{T}.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// The observed value type. internal sealed class StatelessSignalAsyncState { - /// The lock used to synchronize observer list updates. + /// Guards the observer list against concurrent mutation. private readonly Lock _gate = new(); /// Gets or sets the currently subscribed observers. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs index 52e5f8c2..0c328a3a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentReplayLatestSignalAsync.cs @@ -6,10 +6,11 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Represents an asynchronous Signal that replays only the latest value to new observers and supports concurrent notification of observers. +/// An asynchronous Signal that replays its latest value to each new subscriber and notifies observers concurrently. /// The type of the elements processed by the Signal. -/// An optional initial value to be emitted to observers upon subscription if no other value has been published. -/// Observer notifications overlap; their completion order is unspecified. +/// The value replayed to new subscribers until something is published. +/// Completion order across observers is unspecified, and a cancelled notification does not stop the +/// others. [System.Diagnostics.DebuggerDisplay("ConcurrentReplayLatestSignalAsync: LastValue = {_state.LastValue}, IsDisposed = {_state.IsDisposed}")] public sealed class ConcurrentReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs index 892b5425..49c7414d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentSignalAsync.cs @@ -6,9 +6,10 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Provides an asynchronous Signal that forwards notifications to observers concurrently. +/// An asynchronous Signal that notifies its observers concurrently and latches the terminal result. /// The type of value observed and forwarded to observers. -/// Observer notifications overlap; their completion order is unspecified. +/// Completion order across observers is unspecified, and a cancelled notification does not stop the +/// others. [System.Diagnostics.DebuggerDisplay("ConcurrentSignalAsync: Observers = {_state.Observers.Length}, Result = {_state.Result}")] public sealed class ConcurrentSignalAsync : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs index d1a95f2f..015c5b24 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs @@ -6,11 +6,11 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Represents an asynchronous Signal that replays the latest value to new observers and forwards notifications to all observers concurrently without maintaining internal state. +/// An asynchronous Signal that replays its latest value to each new subscriber, notifies observers concurrently, and resets once the last observer leaves. /// The type of the elements processed by the Signal. -/// An optional initial value to be replayed to new observers. If not specified, no value is replayed until the first -/// value is published. -/// Replays the latest value to new subscribers and notifies observers concurrently. Cancellation can prevent some observers from receiving a notification. +/// The value replayed until something is published, and restored when the last observer +/// leaves. +/// Cancellation can stop some observers from receiving a notification the others do receive. [System.Diagnostics.DebuggerDisplay("ConcurrentStatelessReplayLatestSignalAsync: Value = {_state.Value}, IsDisposed = {_state.IsDisposed}")] public sealed class ConcurrentStatelessReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs index 6a7042b7..3ed1e7a4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessSignalAsync.cs @@ -6,9 +6,10 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Represents a stateless asynchronous Signal that forwards notifications to observers concurrently. +/// An asynchronous Signal that notifies its observers concurrently and keeps publishing after a terminal notification. /// The type of the elements processed by the Signal. -/// Observer notifications execute concurrently; their completion order is unspecified. +/// Completion order across observers is unspecified, and a cancelled notification does not stop the +/// others. [System.Diagnostics.DebuggerDisplay("ConcurrentStatelessSignalAsync: Observers = {_state.Observers.Length}")] public sealed class ConcurrentStatelessSignalAsync : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs index b178be7e..7d209929 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/BehaviorSignalCreationOptions.cs @@ -8,12 +8,12 @@ namespace ReactiveUI.Primitives.Async.Signals; [System.Diagnostics.DebuggerDisplay("BehaviorSignalCreationOptions: PublishingOption = {PublishingOption}, IsStateless = {IsStateless}")] public sealed record BehaviorSignalCreationOptions { - /// Gets the default configuration options for creating a new BehaviorSignal instance. + /// Gets the default options: serial publishing, with completion state retained. public static BehaviorSignalCreationOptions Default { get; } = new() { PublishingOption = PublishingOption.Serial, IsStateless = false }; - /// Gets the publishing option to use when processing the item. + /// Gets the order notifications are delivered to observers in. public required PublishingOption PublishingOption { get; init; } - /// Gets a value indicating whether the object is stateless. + /// Gets a value indicating whether the signal discards its completion state, resetting to the start value once the last observer leaves. public required bool IsStateless { get; init; } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs index bf67718e..8ab2f846 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/PublishingOption.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Specifies the available options for publishing operations, indicating whether actions are performed serially or concurrently. +/// Specifies whether a signal awaits each observer notification before the next or starts them together. public enum PublishingOption { /// Awaits each observer notification before invoking the next observer. diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs index b72f9410..2ced9abb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/ReplayLatestSignalCreationOptions.cs @@ -8,13 +8,12 @@ namespace ReactiveUI.Primitives.Async.Signals; [System.Diagnostics.DebuggerDisplay("ReplayLatestSignalCreationOptions: PublishingOption = {PublishingOption}, IsStateless = {IsStateless}")] public sealed record ReplayLatestSignalCreationOptions { - /// Gets the default configuration options for creating a ReplayLatestSignal instance. - /// Defaults to serial, stateful publishing. + /// Gets the default options: serial publishing, with completion state retained. public static ReplayLatestSignalCreationOptions Default { get; } = new() { PublishingOption = PublishingOption.Serial, IsStateless = false }; - /// Gets the publishing option to use when processing this item. + /// Gets the order notifications are delivered to observers in. public required PublishingOption PublishingOption { get; init; } - /// Gets a value indicating whether the object is stateless. + /// Gets a value indicating whether the signal discards its completion state, resetting to the initial value once the last observer leaves. public required bool IsStateless { get; init; } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs index 06023822..679f5cd8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Options/SignalCreationOptions.cs @@ -8,13 +8,12 @@ namespace ReactiveUI.Primitives.Async.Signals; [System.Diagnostics.DebuggerDisplay("SignalCreationOptions: PublishingOption = {PublishingOption}, IsStateless = {IsStateless}")] public sealed record SignalCreationOptions { - /// Gets the default configuration for Signal creation options. - /// Defaults to serial, stateful publishing. + /// Gets the default options: serial publishing, with completion state retained. public static SignalCreationOptions Default { get; } = new() { PublishingOption = PublishingOption.Serial, IsStateless = false }; - /// Gets the publishing option to use when processing the item. + /// Gets the order notifications are delivered to observers in. public required PublishingOption PublishingOption { get; init; } - /// Gets a value indicating whether the object is stateless. + /// Gets a value indicating whether the signal discards its completion state, so values can keep flowing after a terminal notification. public required bool IsStateless { get; init; } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs index 6ace90eb..5e1ea22d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialReplayLatestSignalAsync.cs @@ -6,13 +6,11 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// -/// Represents an asynchronous Signal that replays only the latest value to new subscribers and ensures that -/// notifications are delivered to observers in a serial, thread-safe manner. -/// +/// An asynchronous Signal that replays its latest value to each new subscriber and notifies observers serially. /// The type of the elements processed by the Signal. /// An optional initial value to be emitted to new subscribers before any other values are published. -/// New subscribers receive the latest value, if present. Each observer notification is awaited before invoking the next observer. +/// A signal created without a start value replays nothing until one is published, and each notification +/// finishes before the next observer is called. [System.Diagnostics.DebuggerDisplay("SerialReplayLatestSignalAsync: LastValue = {_state.LastValue}, IsDisposed = {_state.IsDisposed}")] public sealed class SerialReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs index bf1b0429..6150a4f9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialSignalAsync.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Represents an asynchronous Signal that notifies observers in a serial manner, ensuring each observer is notified one at a time. +/// An asynchronous Signal that notifies its observers serially and latches the terminal result. /// The type of the elements processed and observed by the Signal. /// Each observer notification finishes before the next observer is called. [System.Diagnostics.DebuggerDisplay("SerialSignalAsync: Observers = {_state.Observers.Length}, Result = {_state.Result}")] diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs index 91c2a6cc..ed748d0b 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessReplayLatestSignalAsync.cs @@ -6,11 +6,12 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Represents a serial, stateless asynchronous Signal that replays only the last value to new observers and supports asynchronous notification delivery. +/// An asynchronous Signal that replays its latest value to each new subscriber, notifies observers serially, and resets once the last observer leaves. /// The type of the elements processed by the Signal. -/// An optional initial value to be replayed to new observers before any values are published. If not specified, no -/// value is replayed until the first value is received. -/// Replays the latest value, if present, and awaits each observer notification in order. +/// The value replayed until something is published, and restored when the last observer +/// leaves. +/// A signal created without a start value replays nothing until one is published, and each notification +/// finishes before the next observer is called. [System.Diagnostics.DebuggerDisplay("SerialStatelessReplayLatestSignalAsync: State = {_state}")] public sealed class SerialStatelessReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs index 6be57af8..219941cb 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/SerialStatelessSignalAsync.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Async.Signals; -/// Represents a stateless asynchronous Signal that notifies observers of events in a serial, sequential manner. +/// An asynchronous Signal that notifies its observers serially and keeps publishing after a terminal notification. /// The type of the elements processed and observed by the Signal. /// Observers are notified in registration order; each notification is awaited before the next begins. [System.Diagnostics.DebuggerDisplay("SerialStatelessSignalAsync: Observers = {_state.Observers.Length}")] diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs index 735bf65f..64694fc5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Signal.cs @@ -17,19 +17,18 @@ public static class Signal [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: Signal.Create().")] + Justification = "There are no parameters to infer from; the caller states the element type: Signal.Create().")] public static ISignalAsync Create() => new SerialSignalAsync(); /// Creates a new asynchronous Signal instance with the specified publishing and state options. /// The type of elements processed by the Signal. - /// The options that configure the publishing behavior and statefulness of the Signal. Must specify valid values - /// for publishing and statelessness. + /// The publishing order and statelessness to build the signal with. /// An asynchronous Signal instance configured according to the specified options. /// Thrown if the specified combination of publishing and statelessness options is not supported. [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: Signal.Create(options).")] + Justification = "The options carry no element type to infer from; the caller states it: Signal.Create(options).")] public static ISignalAsync Create(SignalCreationOptions? options) => (options?.PublishingOption, options?.IsStateless) switch { @@ -76,20 +75,20 @@ public static ISignalAsync CreateBehavior(T startValue, BehaviorSignalCrea [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: Signal.CreateReplayLatest().")] + Justification = "There are no parameters to infer from; the caller states the element type: Signal.CreateReplayLatest().")] public static ISignalAsync CreateReplayLatest() => new SerialReplayLatestSignalAsync(Optional.Empty); /// Creates a new asynchronous Signal that replays the latest value to new subscribers, with configuration options for publishing behavior and statefulness. /// The type of the elements processed by the Signal. - /// The options that specify the publishing mode and whether the Signal maintains state. Cannot be null. + /// The publishing order and statelessness to build the signal with. Cannot be null. /// An asynchronous Signal that replays the latest value to new subscribers, configured according to the specified /// options. /// Thrown if the combination of options specified in the parameter is not supported. [SuppressMessage( "Design", "SST2307:Generic method type parameters should be inferable from the parameters", - Justification = "Public factory API — caller specifies T explicitly: Signal.CreateReplayLatest(options).")] + Justification = "The options carry no element type to infer from; the caller states it: Signal.CreateReplayLatest(options).")] public static ISignalAsync CreateReplayLatest(ReplayLatestSignalCreationOptions? options) => (options?.PublishingOption, options?.IsStateless) switch { diff --git a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs index e2e0dfb1..e1e6e7f5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs @@ -63,7 +63,7 @@ protected WitnessAsync() /// Delivers a value to , dropping it silently when this observer is disposed, /// the token is cancelled, or another thread holds the notification gate. /// The value to be processed. - /// A cancellation token that can be used to cancel the operation. + /// A token that cancels the delivery; it is linked with this observer's disposal token. /// A task that completes once the core handler and its bookkeeping have run. public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) { @@ -100,7 +100,7 @@ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) /// Routes a non-terminal error to , never propagating a failure back to the producer. /// The exception that triggered the error handling logic. Cannot be null. - /// A cancellation token that can be used to cancel the asynchronous operation. + /// A token that cancels the handling; it is linked with this observer's disposal token. /// A task that completes once the error has been handled. public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) { @@ -441,13 +441,13 @@ protected virtual async ValueTask DisposeAsyncCore() /// Handles non-terminal errors; callback failures reach the unhandled-exception handler. /// The exception that triggered the error handling logic. Cannot be null. - /// A cancellation token that can be used to cancel the asynchronous error handling operation. + /// The effective token for this notification. /// A task that completes when the error has been handled. protected abstract ValueTask OnErrorResumeAsyncCore(Exception error, CancellationToken cancellationToken); /// Delivers the value, forwarding callback failures to error-resume handling and swallowing cancellation. /// The value to be processed. - /// A cancellation token that can be used to cancel the asynchronous operation. + /// The effective token for this notification. /// A task that completes when the value has been handled. protected abstract ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken); diff --git a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs index 3a9848db..1d3a8544 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/ImmediateReturnSignal{T}.cs @@ -16,7 +16,7 @@ public sealed class ImmediateReturnSignal : IRequireCurrentThread, IInline private readonly T _value; /// Initializes a new instance of the class. - /// The value. + /// The value emitted to every subscriber. public ImmediateReturnSignal(T value) => _value = value; /// Indicates whether subscription has to happen on the calling thread. diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs b/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs index d2f17bc4..ba06a5df 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs @@ -5,10 +5,8 @@ namespace ReactiveUI.Primitives.Advanced; /// -/// Helpers for the interlocked single-assignment subscription slots shared by the catch-style sinks -/// (RecoverSignal, ResumeSignal): a slot holds at most one live -/// subscription and, once the sink is disposed, swaps to a sentinel so a late assignment is disposed instead of -/// stored. +/// Interlocked single-assignment subscription slots for a sink that resubscribes: a slot holds at most one live +/// subscription and, once released, swaps to a sentinel so a late assignment is disposed instead of stored. /// public static class SubscriptionSlots { diff --git a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs index 743a54c6..b1c697c4 100644 --- a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs +++ b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs @@ -8,9 +8,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// Provides the base implementation for a scheduled unit of work that is ordered by an absolute due time. -/// -/// The absolute-time representation, which must be comparable so scheduled items can be ordered. -/// +/// The absolute-time representation, which must be comparable so items can be ordered. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ScheduledItem : IScheduledItem, IComparable>, IsDisposed, IComparable @@ -22,16 +20,13 @@ public sealed class ScheduledItem : IScheduledItem, ICompa /// The work performed when the item is invoked; receives this item so callers can self-deregister. private readonly Func, IDisposable> _invokeCore; - /// Invocation disposable. + /// The disposable returned by the invoked work, latched once so cancellation can reach it. private IDisposable? _disposable; - /// Disposal flag. + /// Non-zero once the item has been canceled or disposed. private int _isDisposed; - /// - /// Initializes a new instance of the class with the due time, comparer, - /// and the callback that supplies the work performed by . - /// + /// Initializes a new instance of the class. /// The absolute time at which this item is due to run. /// The comparer that orders due-time values. /// @@ -52,14 +47,10 @@ public ScheduledItem( /// Gets the absolute time at which this work item is scheduled to run. public TAbsolute DueTime { get; } - /// Gets a value indicating whether this work item has been canceled or disposed. - /// - /// Once this is , starts no new work, and a disposable returned by - /// work that has begun is disposed as soon as it is available. - /// + /// Gets a value indicating whether this work item has been canceled or disposed, so starts no work. public bool IsDisposed => Volatile.Read(ref _isDisposed) != 0; - /// Gets the Debugger text. + /// Gets the debugger display text. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; @@ -125,7 +116,7 @@ public ScheduledItem( public static bool operator >=(ScheduledItem left, ScheduledItem right) => Comparer>.Default.Compare(left, right) >= 0; - /// Cancels this scheduled work item: work that has not started does not run, and a disposable returned by work that has begun is disposed as soon as it is available. + /// Cancels this scheduled work item; an alias for . [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Cancel() => Dispose(); diff --git a/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs b/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs index b2c6b61f..02c3d16a 100644 --- a/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs +++ b/src/ReactiveUI.Primitives.Core/Concurrency/SequencerQueue.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// Scheduler queue that keeps scheduled items ordered by absolute due time. /// Absolute time representation type. -/// This type is not thread safe; users should ensure proper synchronization. +/// This type is not thread safe; concurrent callers must provide their own synchronization. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public class SequencerQueue where TAbsolute : IComparable diff --git a/src/ReactiveUI.Primitives.Core/Core/IObserver{TValue,TResult}.cs b/src/ReactiveUI.Primitives.Core/Core/IObserver{TValue,TResult}.cs index 8b108948..b78a009e 100644 --- a/src/ReactiveUI.Primitives.Core/Core/IObserver{TValue,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Core/IObserver{TValue,TResult}.cs @@ -5,14 +5,8 @@ namespace ReactiveUI.Primitives.Core; /// Provides a mechanism for receiving push-based notifications and returning a response. -/// -/// The type of the elements received by the observer. -/// This type parameter is contravariant. That is, you can use either the type you specified or any type that is less derived. -/// -/// -/// The type of the result returned from the observer's notification handlers. -/// This type parameter is covariant. That is, you can use either the type you specified or any type that is more derived. -/// +/// The element type received by the observer. +/// The result type returned from the observer's notification handlers. public interface IObserver { /// Notifies the observer of a new element in the sequence. diff --git a/src/ReactiveUI.Primitives.Core/Core/PriorityQueue.cs b/src/ReactiveUI.Primitives.Core/Core/PriorityQueue.cs index 3f59b71b..de594545 100644 --- a/src/ReactiveUI.Primitives.Core/Core/PriorityQueue.cs +++ b/src/ReactiveUI.Primitives.Core/Core/PriorityQueue.cs @@ -24,10 +24,10 @@ public sealed class PriorityQueue /// Offset from a node's doubled index to its right child. private const int RightChildOffset = 2; - /// Capacity divisor used to shrink sparse queues. + /// Capacity divisor that decides when a sparse queue shrinks. private const int ShrinkDivisor = 4; - /// Offset from to the last index that can still have a child. + /// Offset from to the last index with a child. private const int LastParentCountOffset = 2; /// Monotonic tie-breaker for equal-priority items. @@ -251,7 +251,6 @@ private void Heapify(int index) break; } - // swap index and first (_items[first], _items[index]) = (_items[index], _items[first]); index = first; } @@ -276,7 +275,6 @@ private int Percolate(int index) var parent = (index - 1) / HeapBranchingFactor; while (parent >= 0 && parent != index && IsHigherPriority(index, parent)) { - // swap index and parent (_items[parent], _items[index]) = (_items[index], _items[parent]); index = parent; parent = (index - 1) / HeapBranchingFactor; diff --git a/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs b/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs index a03a9060..2a89f996 100644 --- a/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs @@ -8,27 +8,27 @@ namespace ReactiveUI.Primitives.Core; /// -/// Represents a value associated with time interval information. -/// The time interval can represent the time it took to produce the value, the interval relative to a previous value, the value's delivery time relative to a base, etc. +/// Represents a value paired with a time interval, whose meaning is the producer's: how long the value took to +/// produce, the gap since the previous value, or its delivery time relative to a base. /// -/// The type of the value being annotated with time interval information. +/// The annotated value type. [Serializable] [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public readonly struct TimeInterval : IEquatable> { /// Initializes a new instance of the struct. - /// The value to be annotated with a time interval. - /// Time interval associated with the value. + /// The value to annotate. + /// The time interval associated with the value. public TimeInterval(T value, TimeSpan interval) { Interval = interval; Value = value; } - /// Gets the value. + /// Gets the annotated value. public T Value { get; } - /// Gets the interval. + /// Gets the time interval associated with the value. public TimeSpan Interval { get; } /// Gets the debugger display text. @@ -36,34 +36,29 @@ public TimeInterval(T value, TimeSpan interval) [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Determines whether the two specified TimeInterval values have the same Value and Interval. - /// The first TimeInterval value to compare. - /// The second TimeInterval value to compare. - /// true if the first TimeInterval value has the same Value and Interval as the second TimeInterval value; otherwise, false. + /// Compares two annotated values for equality. + /// First value. + /// Second value. + /// true when both values and intervals are equal. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator ==(TimeInterval first, TimeInterval second) => first.Equals(second); - /// Determines whether the two specified TimeInterval values don't have the same Value and Interval. - /// The first TimeInterval value to compare. - /// The second TimeInterval value to compare. - /// true if the first TimeInterval value has a different Value or Interval as the second TimeInterval value; otherwise, false. + /// Compares two annotated values for inequality. + /// First value. + /// Second value. + /// true when either value or interval differs. public static bool operator !=(TimeInterval first, TimeInterval second) => !first.Equals(second); - /// Determines whether the current TimeInterval value has the same Value and Interval as a specified TimeInterval value. - /// An object to compare to the current TimeInterval value. - /// true if both TimeInterval values have the same Value and Interval; otherwise, false. + /// public bool Equals(TimeInterval other) => other.Interval.Equals(Interval) && EqualityComparer.Default.Equals(Value, other.Value); - /// Determines whether the specified System.Object is equal to the current TimeInterval. - /// The System.Object to compare with the current TimeInterval. - /// true if the specified System.Object is equal to the current TimeInterval; otherwise, false. + /// public override bool Equals(object? obj) => obj is TimeInterval other && Equals(other); - /// Returns the hash code for the current TimeInterval value. - /// A hash code for the current TimeInterval value. + /// public override int GetHashCode() { var valueHashCode = Value is null ? 1963 : EqualityComparer.Default.GetHashCode(Value); @@ -71,8 +66,7 @@ public override int GetHashCode() return Interval.GetHashCode() ^ valueHashCode; } - /// Returns a string representation of the current TimeInterval value. - /// String representation of the current TimeInterval value. + /// public override string ToString() => #if NET8_0_OR_GREATER string.Format(CultureInfo.CurrentCulture, CoreCompositeFormats.TimeInterval, Value, Interval); diff --git a/src/ReactiveUI.Primitives.Core/Optional.cs b/src/ReactiveUI.Primitives.Core/Optional.cs index 18339261..201f27c5 100644 --- a/src/ReactiveUI.Primitives.Core/Optional.cs +++ b/src/ReactiveUI.Primitives.Core/Optional.cs @@ -22,11 +22,11 @@ public readonly record struct Optional public Optional() => (_value, HasValue) = (default, false); /// Initializes a new instance of the struct. - /// The value to be contained in the instance. + /// The value to be contained in the instance. public Optional([AllowNull] T value) => (_value, HasValue) = value is null ? (default, false) : (value, true); /// Initializes a new instance of the struct. - /// The value. + /// The value to contain, treated as absent when . /// A value indicating whether a value is present. private Optional([AllowNull] T value, bool hasValue) => (_value, HasValue) = hasValue && value is not null ? (value, true) : (default, false); @@ -53,7 +53,7 @@ private Optional([AllowNull] T value, bool hasValue) => public static Optional Some([AllowNull] T value) => new(value, true); /// Implicit cast from the value to the optional. - /// The value. + /// The value to wrap, treated as absent when . /// The optional value. public static implicit operator Optional([AllowNull] T value) => ToOptional(value); @@ -63,13 +63,13 @@ private Optional([AllowNull] T value, bool hasValue) => public static explicit operator T?(in Optional value) => FromOptional(value); /// Creates an optional value, treating a value as absent. - /// The value. + /// The value to wrap. /// The optional value. public static Optional Create([AllowNull] T value) => new(value); /// Gets the value from the optional value. - /// The optional value. - /// The value. + /// The optional value to unwrap. + /// The contained value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static T? FromOptional(in Optional value) => value.Value; diff --git a/src/ReactiveUI.Primitives.Core/Result.cs b/src/ReactiveUI.Primitives.Core/Result.cs index 9206c34d..6a378af4 100644 --- a/src/ReactiveUI.Primitives.Core/Result.cs +++ b/src/ReactiveUI.Primitives.Core/Result.cs @@ -24,16 +24,14 @@ public Result(Exception exception) /// Gets a predefined result instance that indicates a successful operation. public static Result Success => default; - /// Gets the exception that caused the current operation to fail, if any. + /// Gets the exception that caused the operation to fail, or when it succeeded. public Exception? Exception { get; } - /// Gets a value indicating whether the operation completed successfully without an exception. - /// When , is non-null. + /// Gets a value indicating whether the operation completed without an exception. [MemberNotNullWhen(false, nameof(Exception))] public bool IsSuccess => Exception is null; /// Gets a value indicating whether the operation has failed. - /// When , is non-null. [MemberNotNullWhen(true, nameof(Exception))] public bool IsFailure => Exception is not null; @@ -57,7 +55,6 @@ public void TryThrow() } /// Returns a string that represents the result status of the operation. - /// A string indicating "Success" if the operation was successful; otherwise, a string in the format - /// "Failure{exception message}" containing the associated exception message. + /// Success, or Failure{message} carrying the failure exception's message. public override string ToString() => IsSuccess ? "Success" : $"Failure{{{Exception.Message}}}"; } diff --git a/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs b/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs index 6b545449..b412ba6a 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs @@ -8,38 +8,31 @@ namespace ReactiveUI.Primitives.Signals; -/// A signal that exposes its next value as an awaitable operation. -/// The Type. -/// +/// A signal that records the latest value and replays it to observers when it completes, so the completion can be awaited. +/// The observed value type. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class AsyncSignal : IAwaitSignal { /// Serializes observer changes and terminal-state transitions. private readonly Lock _observerLock = new(); - /// Stores state for the signal implementation. + /// The most recent value, replayed when the signal completes. private T? _lastValue; - /// Stores state for the signal implementation. + /// Whether a value has been recorded. private bool _hasValue; - /// Stores state for the signal implementation. + /// The terminal error, when the signal faulted. private Exception? _lastError; - /// Stores state for the signal implementation. + /// The dispatch target: the empty witness, a single observer, or a fan-out. private IObserver _outObserver = EmptyWitness.Instance; /// Gets a value indicating whether this instance is disposed. - /// - /// true if this instance is disposed; otherwise, false. - /// public bool IsDisposed { get; private set; } - /// Gets the value. - /// - /// The value. - /// - /// The final signal is not completed yet. + /// Gets the value the signal completed with. + /// The signal has not completed. public T Value { get @@ -56,16 +49,10 @@ public T Value } } - /// Gets a value indicating whether this instance has observers. - /// - /// true if this instance has observers; otherwise, false. - /// + /// Gets a value indicating whether an observer is subscribed and the signal has neither completed nor been disposed. public bool HasObservers => _outObserver is not EmptyWitness && !IsCompleted && !IsDisposed; /// Gets a value indicating whether this instance is completed. - /// - /// true if this instance is completed; otherwise, false. - /// public bool IsCompleted { get; private set; } /// Gets the debugger display text. @@ -73,7 +60,7 @@ public T Value [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Called when [completed]. + /// Completes the signal, emitting the recorded value to the current observers first when one was recorded. public void OnCompleted() { IObserver observers; @@ -105,9 +92,9 @@ public void OnCompleted() } } - /// Specifies a callback action that will be invoked when the subject completes. - /// Callback action that will be invoked when the subject completes. - /// is null. + /// Registers a callback to run when the signal terminates, on the captured synchronization context. + /// The callback to run on completion or failure. + /// is . public void OnCompleted(Action continuation) { ArgumentExceptionHelper.ThrowIfNull(continuation); @@ -115,9 +102,9 @@ public void OnCompleted(Action continuation) SubscribeCompletion(continuation, true); } - /// Called when [error]. - /// The error. - /// error. + /// Faults the signal and forwards to the current observers, discarding any recorded value. + /// The terminal error. + /// is . public void OnError(Exception error) { ArgumentExceptionHelper.ThrowIfNull(error); @@ -140,8 +127,8 @@ public void OnError(Exception error) observers.OnError(error); } - /// Called when [next]. - /// The value. + /// Records as the value replayed on completion; observers are not notified here. + /// The value to record. public void OnNext(T value) { lock (_observerLock) @@ -157,10 +144,10 @@ public void OnNext(T value) } } - /// Subscribes the specified observer. - /// The observer. - /// A Disposable. - /// observer. + /// Subscribes an observer, delivering the terminal value or error immediately when the signal has completed. + /// The observer to subscribe. + /// A handle that removes the observer when disposed. + /// is . public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -211,7 +198,7 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Releases unmanaged and - optionally - managed resources. + /// Drops the observers and the recorded value, making every later notification throw. public void Dispose() { if (IsDisposed) @@ -229,13 +216,13 @@ public void Dispose() IsDisposed = true; } - /// Gets an awaitable object for the current final signal. - /// Object that can be awaited. + /// Gets the awaiter for this signal. + /// The signal itself, which acts as its own awaiter. public IAwaitSignal GetAwaiter() => this; - /// Gets the last element of the subject, potentially blocking until the subject completes successfully or exceptionally. - /// The last element of the subject. Throws an InvalidOperationException if no element was received. - /// The source sequence is empty. + /// Gets the completed value, blocking the calling thread until the signal completes or faults. + /// The recorded value, after rethrowing the terminal error when the signal faulted. + /// The signal completed without recording a value. public T GetResult() { WaitIfPending(WaitForCompletion); @@ -249,7 +236,7 @@ public T GetResult() return _lastValue!; } - /// Removes an observer previously registered via . Called by the observer's subscription handle when it is disposed. + /// Removes an observer registered via ; the observer's subscription handle calls this on disposal. /// The observer to remove. public void RemoveObserver(IObserver observer) { @@ -285,7 +272,7 @@ private static void WaitForCompletion(AsyncSignal signal) } /// Rejects operations after the signal has been disposed. - /// This instance has already been disposed. + /// The signal is disposed. private void ThrowIfDisposed() { if (!IsDisposed) @@ -297,8 +284,8 @@ private void ThrowIfDisposed() } /// Registers a callback for either terminal notification. - /// The continuation value. - /// The originalContext value. + /// The callback invoked on the terminal notification. + /// Whether to resume the callback on the captured synchronization context. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void SubscribeCompletion(Action continuation, bool originalContext) => Subscribe(new AwaitWitness(continuation, originalContext)); diff --git a/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs b/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs index 4cd4a90c..89f66d2a 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs @@ -6,20 +6,20 @@ namespace ReactiveUI.Primitives.Signals; -/// Represents the AwaitWitness class. -/// The Type. +/// Observer that discards values and runs a continuation on the first terminal notification. +/// The observed value type. [System.Diagnostics.DebuggerDisplay("AwaitWitness: Callback = {_callback}, Context = {_context}")] public sealed class AwaitWitness : IObserver { - /// Stores state for the signal implementation. + /// The context captured at construction, or to run the continuation inline. private readonly SynchronizationContext? _context; - /// Stores state for the signal implementation. + /// The continuation run on completion or failure. private readonly Action _callback; /// Initializes a new instance of the class. - /// The callback value. - /// The originalContext value. + /// The continuation to run on the terminal notification. + /// Whether to capture the current synchronization context and post the continuation to it. public AwaitWitness(Action callback, bool originalContext) { if (originalContext) @@ -35,19 +35,16 @@ public AwaitWitness(Action callback, bool originalContext) public void OnCompleted() => InvokeOnOriginalContext(); /// Resumes the awaiting continuation when the source fails. - /// The error value. + /// The terminal error, which the continuation does not receive. [MethodImpl(MethodImplOptions.AggressiveInlining)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "OnCompleted and OnError are distinct IObserver terminal notifications that intentionally both fire the " - + "witness by delegating to the shared InvokeOnOriginalContext helper. They are not a copy-paste of each " - + "other; having one call the other would misrepresent an error as a completion.")] + Justification = "Both terminal notifications resume the same continuation, and neither may delegate to the other.")] public void OnError(Exception error) => InvokeOnOriginalContext(); /// Ignores values; only terminal notifications resume the continuation. - /// The value. + /// The ignored value. public void OnNext(T value) { } diff --git a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs index 0df1f8c7..16da1d35 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Signals; [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "SST1803:Make record struct readonly", - Justification = "This is mutable signal state; its members mutate the fields in place, so it cannot be readonly.")] + Justification = "The members mutate these fields in place.")] internal record struct BehaviorSignalState { /// Protects observer and terminal-state mutations. @@ -21,8 +21,7 @@ internal record struct BehaviorSignalState [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "SST1424:Make field readonly", - Justification = - "Broadcaster is a mutable struct; readonly fields would mutate defensive copies and lose observer updates.")] + Justification = "A readonly field would mutate a defensive copy of this mutable struct and lose observer updates.")] private Broadcaster _broadcaster; /// The last error, when terminated exceptionally. @@ -137,7 +136,7 @@ internal void OnNext(T value) } /// Subscribes an observer, replaying the current value or terminal notification. - /// The owning signal used to remove the observer on disposal. + /// The owning signal that the returned handle removes the observer from. /// The observer to subscribe. /// A handle that unsubscribes the observer when disposed. internal IDisposable Subscribe(IWitnessRemovable owner, IObserver observer) @@ -173,7 +172,7 @@ internal IDisposable Subscribe(IWitnessRemovable owner, IObserver observer return EmptyDisposable.Instance; } - /// Removes a previously subscribed observer. + /// Removes a subscribed observer from the broadcaster. /// The observer to remove. internal void RemoveObserver(IObserver observer) { @@ -200,7 +199,7 @@ internal void Release() } /// Throws when the signal has been disposed. - /// The signal has already been released. + /// The signal is released. private readonly void ThrowIfDisposed() { if (_isDisposed == 0) diff --git a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs index ba80e2d5..f7c525d9 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignal{T}.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Signals; /// A signal that replays its most recent value to new subscribers. -/// The Type. +/// The value type. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class BehaviorSignal : ISignal, IWitnessRemovable { @@ -18,31 +18,24 @@ public sealed class BehaviorSignal : ISignal, IWitnessRemovable /// The default value. public BehaviorSignal(T defaultValue) => _state = new(defaultValue); - /// Gets the current value or throws an exception. - /// The initial value passed to the constructor until is called; after which, the last value passed to . - /// Completion freezes the value. Failure makes value reads throw the terminal exception, and disposal makes them throw ObjectDisposedException. + /// Gets the most recent value, which is the constructor's default until supplies one. + /// Completion freezes the value; a failure makes reads throw the terminal error and disposal makes them throw . public T Value => _state.GetValue(); /// Gets a value indicating whether this instance has observers. - /// - /// true if this instance has observers; otherwise, false. - /// public bool HasObservers => _state.HasObservers; /// Gets a value indicating whether this instance is disposed. - /// - /// true if this instance is disposed; otherwise, false. - /// public bool IsDisposed => _state.IsDisposed; - /// Gets the string representation of this object for debugger display purposes. + /// Gets the debugger display text. [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string? DebuggerDisplay => ToString(); - /// Tries to get the current value or throws an exception. - /// The initial value passed to the constructor until is called; after which, the last value passed to . - /// true if a value is available; false if the subject was disposed. - /// Completion freezes the value; failure makes reads throw the terminal exception. Each read is synchronized, but a separate read and state check are not atomic. + /// Tries to read the most recent value. + /// The most recent value, or when the signal is disposed. + /// when a value is available; when the signal is disposed. + /// A read throws the terminal error after a failure, and is not atomic with a separate state check. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGetValue(out T? value) => _state.TryGetValue(out value); @@ -61,14 +54,14 @@ public sealed class BehaviorSignal : ISignal, IWitnessRemovable [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(T value) => _state.OnNext(value); - /// Subscribes an observer to the subject. - /// Observer to subscribe to the subject. - /// Disposable object that can be used to unsubscribe the observer from the subject. + /// Subscribes an observer, replaying the current value or the terminal notification. + /// The observer to subscribe. + /// A handle that unsubscribes the observer when disposed. /// is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => _state.Subscribe(this, observer); - /// Releases unmanaged and - optionally - managed resources. + /// Drops the observers and the cached value, making later reads throw. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => _state.Release(); diff --git a/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs index 62f76e23..cf8679b2 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs @@ -37,7 +37,7 @@ public struct Broadcaster : IEquatable> /// when the broadcasters reference different observer sets; otherwise, . public static bool operator !=(Broadcaster left, Broadcaster right) => !left.Equals(right); - /// Adds an observer to the broadcaster. The update is a lock-free compare-and-swap, so the broadcaster is self-contained and does not rely on an external lock for correctness. + /// Adds an observer with a lock-free compare-and-swap, so no external lock is required. /// Observer to add. public void Add(IObserver observer) { @@ -174,7 +174,7 @@ public override readonly bool Equals(object? obj) => [SuppressMessage( "Maintainability", "SST1482:GetHashCode reads mutable state", - Justification = "Equality is the observer set, so the hash must follow it; a Broadcaster is compared, never used as a hash key.")] + Justification = "Equality compares the observer set, so the hash must follow it.")] public override readonly int GetHashCode() { var snapshot = _observers; diff --git a/src/ReactiveUI.Primitives.Core/Signals/CommandExecution{TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/CommandExecution{TResult}.cs index c47ec0ec..7acbf1e0 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/CommandExecution{TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/CommandExecution{TResult}.cs @@ -116,8 +116,7 @@ public Awaiter(Task? task, TResult? result, Exception? exception, bool [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = - "Awaiter GetResult must be synchronous; it runs only after completion and unwraps exceptions without AggregateException wrapping.")] + Justification = "An awaiter's GetResult is synchronous by contract and runs only once the task is complete.")] public TResult GetResult() { if (_task is not null) diff --git a/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs index 25412439..41fd43ed 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs @@ -11,10 +11,10 @@ namespace ReactiveUI.Primitives.Signals; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class CommandSignal : IObservable, IDisposable { - /// Stores asynchronous command execution. + /// The asynchronous command body, or for a synchronous command. private readonly Func>? _executeAsync; - /// Stores synchronous command execution. + /// The synchronous command body, or for an asynchronous command. private readonly Func? _executeSync; /// Serializes running-flag writes with running-state stream notifications so the two never diverge. @@ -29,10 +29,10 @@ public sealed class CommandSignal : IObservable, IDisposable /// Lazily created running state stream. private StateSignal? _isRunningState; - /// Stores state for the signal implementation. + /// The subscription to the gating signal, or when the command is ungated. private IDisposable? _canRunSubscription; - /// Stores state for the signal implementation. + /// The latest value from the gating signal. private bool _canRun; /// Stores the current running flag without forcing the public state stream to allocate. @@ -41,7 +41,7 @@ public sealed class CommandSignal : IObservable, IDisposable /// Non-zero while an execution is active. private int _running; - /// Stores disposal state. + /// Non-zero once the command has been disposed. private int _disposed; /// Initializes a new instance of the class. @@ -154,7 +154,7 @@ public IDisposable Subscribe(IObserver observer) /// Attempts to append an observer to an unchanged snapshot. /// The published observer snapshot. - /// The snapshot used to prepare the update. + /// The snapshot the update was prepared against. /// The observer to append. /// Whether the update was published. internal static bool TryAddResult(ref object? storage, object? current, IObserver observer) @@ -181,7 +181,7 @@ internal static bool TryAddResult(ref object? storage, object? current, IObserve /// Attempts to remove an observer from an unchanged snapshot. /// The published observer snapshot. - /// The snapshot used to prepare the update. + /// The snapshot the update was prepared against. /// The observer to remove. /// Whether no retry is required. internal static bool TryRemoveResult(ref object? storage, object? current, IObserver observer) @@ -326,7 +326,7 @@ private async Task ExecuteAsyncCore(CancellationToken cancellationToken } /// Enters the running state after validating gate state. - /// The command is gated off or an execution is already in flight. + /// The command is gated off or another execution is in flight. private void BeginExecution() { if (!CanRun || Interlocked.CompareExchange(ref _running, 1, 0) != 0) diff --git a/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs index c8d64a50..602e60a7 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Signals; /// /// A signal that passes notifications through immediately while they are not delayed, but buffers them while delayed /// and emits a de-duplicated batch when is called (typically as the delay window opens or closes). -/// Fuses the Buffer(boundary).SelectMany(distinct).Publish().RefCount() pipeline into one allocation-light sink. +/// Equivalent to the Buffer(boundary).SelectMany(distinct).Publish().RefCount() pipeline in a single sink. /// /// The notification type. [System.Diagnostics.DebuggerDisplay("DelayableNotificationSignal: Stopped = {_stopped}, Buffer = {_buffer}")] diff --git a/src/ReactiveUI.Primitives.Core/Signals/IAwaitSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/IAwaitSignal{T}.cs index bea95cbd..2d79842f 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/IAwaitSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/IAwaitSignal{T}.cs @@ -5,22 +5,17 @@ namespace ReactiveUI.Primitives.Signals; /// A signal whose next value can be awaited. -/// The Type of Signal. -/// -/// +/// The value type. public interface IAwaitSignal : ISignal, System.Runtime.CompilerServices.INotifyCompletion { - /// Gets a value indicating whether this instance is completed. - /// - /// true if this instance is completed; otherwise, false. - /// + /// Gets a value indicating whether the signal has terminated, so an awaiting continuation runs inline. bool IsCompleted { get; } - /// Gets the awaiter. - /// An IAwaitSignal. + /// Gets the awaiter for this signal. + /// The signal itself, which acts as its own awaiter. IAwaitSignal GetAwaiter(); - /// Gets the result. - /// A value of T. + /// Gets the awaited value, rethrowing the terminal error when the signal faulted. + /// The value the signal terminated with. T GetResult(); } diff --git a/src/ReactiveUI.Primitives.Core/Signals/ISignal{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/ISignal{TSource,TResult}.cs index 59be5d58..9b47ff7d 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ISignal{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ISignal{TSource,TResult}.cs @@ -7,13 +7,10 @@ namespace ReactiveUI.Primitives.Signals; /// A signal that observes one type and emits another. -/// The type of the source. -/// The type of the result. +/// The observed value type. +/// The emitted value type. public interface ISignal : IObserver, IObservable, IsDisposed { /// Gets a value indicating whether this instance has observers. - /// - /// true if this instance has observers; otherwise, false. - /// bool HasObservers { get; } } diff --git a/src/ReactiveUI.Primitives.Core/Signals/ISignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/ISignal{T}.cs index cf5d6929..5bd4b2a9 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ISignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ISignal{T}.cs @@ -5,5 +5,5 @@ namespace ReactiveUI.Primitives.Signals; /// A signal that is both an observer and observable of the same type. -/// The Type. +/// The value type observed and emitted. public interface ISignal : ISignal; diff --git a/src/ReactiveUI.Primitives.Core/Signals/ITaskSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/ITaskSignal{T}.cs index f5e8988c..727db516 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ITaskSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ITaskSignal{T}.cs @@ -7,29 +7,19 @@ namespace ReactiveUI.Primitives.Signals; /// A signal backed by a task. -/// The object that provides notification information. -/// +/// The value type. public interface ITaskSignal : IObservable, IsDisposed { - /// Gets the cancellation token source. - /// - /// The cancellation token source. - /// + /// Gets the source that cancels the backing task, or when the signal is not cancellable. CancellationTokenSource? CancellationTokenSource { get; } - /// Gets a value indicating whether this instance is cancellation requested. - /// - /// true if this instance is cancellation requested; otherwise, false. - /// + /// Gets a value indicating whether cancellation of the backing task has been requested. bool IsCancellationRequested { get; } - /// Gets the source. - /// - /// The source. - /// + /// Gets the sequence the backing task produces, or before the task starts. IObservable? Source { get; } - /// Gets the operation canceled. - /// The observer. + /// Notifies an observer with the exception produced by cancelling the backing task. + /// The observer that receives the cancellation exception. void GetOperationCanceled(IObserver observer); } diff --git a/src/ReactiveUI.Primitives.Core/Signals/IWitnessRemovable{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/IWitnessRemovable{T}.cs index 6614eceb..e7ac23b8 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/IWitnessRemovable{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/IWitnessRemovable{T}.cs @@ -4,11 +4,11 @@ namespace ReactiveUI.Primitives.Signals; -/// A signal that can remove a previously subscribed observer when its subscription handle is disposed. +/// A signal that a subscription handle can detach its observer from when the handle is disposed. /// The observed value type. internal interface IWitnessRemovable { - /// Removes an observer previously registered via subscription. + /// Removes a subscribed observer from the signal. /// The observer to remove. void RemoveObserver(IObserver observer); } diff --git a/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs index 5beab2a7..0134e6cc 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/KeepSignal{T}.cs @@ -6,27 +6,27 @@ namespace ReactiveUI.Primitives.Signals; -/// Represents the KeepSignal class. -/// The T type. -/// The source value. -/// The predicate value. +/// Signal that forwards only the source values the predicate accepts. +/// The value type. +/// The source sequence. +/// The predicate applied to each source value. [System.Diagnostics.DebuggerDisplay("KeepSignal: Source = {_source}, Predicate = {_predicate}")] public sealed class KeepSignal(IObservable source, Func predicate) : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The source sequence. private readonly IObservable _source = source; - /// Stores state for the signal implementation. + /// The predicate applied to each source value. private readonly Func _predicate = predicate; /// Preserves the source's current-thread subscription requirement. - /// The result. + /// when the source requires current-thread subscription. public bool IsRequiredSubscribeOnCurrentThread() => _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); /// Subscribes an observer to source values accepted by the predicate. - /// The observer value. - /// The result. + /// The downstream observer. + /// The subscription handle. public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -34,18 +34,18 @@ public IDisposable Subscribe(IObserver observer) return _source.Subscribe(new KeepWitness(observer, _predicate)); } - /// Represents the KeepWitness class. - /// The observer value. - /// The predicate value. + /// Applies the predicate to each source value and forwards the ones it accepts. + /// The downstream observer. + /// The predicate applied to each source value. private sealed class KeepWitness(IObserver observer, Func predicate) : IObserver { - /// Stores state for the signal implementation. + /// The downstream observer. private readonly IObserver _observer = observer; - /// Stores state for the signal implementation. + /// The predicate applied to each source value. private readonly Func _predicate = predicate; - /// Stores state for the signal implementation. + /// Non-zero once a terminal notification has been forwarded. private int _stopped; /// Forwards completion only while the sink is active. @@ -60,7 +60,7 @@ public void OnCompleted() } /// Stops the sink and forwards its first error. - /// The error value. + /// The terminal error. public void OnError(Exception error) { if (Interlocked.Exchange(ref _stopped, 1) != 0) @@ -72,7 +72,7 @@ public void OnError(Exception error) } /// Filters active values and turns predicate failures into terminal errors. - /// The value. + /// The source value. public void OnNext(T value) { if (Volatile.Read(ref _stopped) != 0) diff --git a/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs b/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs index 795ebb02..48ec0161 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/KeepWithSignal{T,TState}.cs @@ -69,7 +69,7 @@ public void OnCompleted() } /// Forwards an error downstream. - /// The error value. + /// The terminal error. public void OnError(Exception error) { if (Interlocked.Exchange(ref _stopped, 1) != 0) diff --git a/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs index 35e72aff..40bd40b2 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/MapSignal{TSource,TResult}.cs @@ -6,28 +6,28 @@ namespace ReactiveUI.Primitives.Signals; -/// Represents the MapSignal class. -/// The TSource type. -/// The TResult type. -/// The source value. -/// The selector value. +/// Signal that projects each source value through a selector before forwarding it. +/// The source value type. +/// The projected value type. +/// The source sequence. +/// The projection applied to each source value. [System.Diagnostics.DebuggerDisplay("MapSignal: Source = {_source}, Selector = {_selector}")] public sealed class MapSignal(IObservable source, Func selector) : IRequireCurrentThread { - /// Stores state for the signal implementation. + /// The source sequence. private readonly IObservable _source = source; - /// Stores state for the signal implementation. + /// The projection applied to each source value. private readonly Func _selector = selector; /// Preserves the source's current-thread subscription requirement. - /// The result. + /// when the source requires current-thread subscription. public bool IsRequiredSubscribeOnCurrentThread() => _source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread(); - /// Subscribes an observer to the selected source values. - /// The observer value. - /// The result. + /// Subscribes an observer to the projected source values. + /// The downstream observer. + /// The subscription handle. public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -35,18 +35,18 @@ public IDisposable Subscribe(IObserver observer) return _source.Subscribe(new MapWitness(observer, _selector)); } - /// Represents the MapWitness class. - /// The observer value. - /// The selector value. + /// Applies the selector to each source value and forwards the projection. + /// The downstream observer. + /// The projection applied to each source value. private sealed class MapWitness(IObserver observer, Func selector) : IObserver { - /// Stores state for the signal implementation. + /// The downstream observer. private readonly IObserver _observer = observer; - /// Stores state for the signal implementation. + /// The projection applied to each source value. private readonly Func _selector = selector; - /// Stores state for the signal implementation; non-zero once the sink has terminated. + /// Non-zero once a terminal notification has been forwarded. private int _stopped; /// Forwards completion only while the sink is active. @@ -61,7 +61,7 @@ public void OnCompleted() } /// Stops the sink and forwards its first error. - /// The error value. + /// The terminal error. public void OnError(Exception error) { if (Interlocked.Exchange(ref _stopped, 1) != 0) @@ -73,7 +73,7 @@ public void OnError(Exception error) } /// Projects active values and turns selector failures into terminal errors. - /// The value. + /// The source value. public void OnNext(TSource value) { if (Volatile.Read(ref _stopped) != 0) diff --git a/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs index f8ba2089..41468767 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs @@ -79,7 +79,7 @@ public void OnCompleted() } /// Forwards an error downstream. - /// The error value. + /// The terminal error. public void OnError(Exception error) { if (Interlocked.Exchange(ref _stopped, 1) != 0) diff --git a/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs b/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs index 9cc1c700..7e954d43 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ObserverHandler.cs @@ -4,21 +4,21 @@ namespace ReactiveUI.Primitives.Signals; -/// Represents the ObserverHandler class. -/// The Type. -/// The subject value. -/// The observer value. +/// Subscription handle that removes its observer from the signal exactly once when disposed. +/// The observed value type. +/// The signal the observer is subscribed to. +/// The subscribed observer. [System.Diagnostics.DebuggerDisplay("ObserverHandler: Subject = {_subject}, Observer = {_observer}")] public sealed class ObserverHandler(AsyncSignal subject, IObserver observer) : IDisposable { /// Serializes detachment from the signal. private readonly Lock _gate = new(); - /// Stores state for the signal implementation. + /// The signal to unsubscribe from, cleared by the first disposal. [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Back-reference to the subject; unsubscribed, not owned.")] private AsyncSignal? _subject = subject; - /// Stores state for the signal implementation. + /// The observer to remove, cleared by the first disposal. private IObserver? _observer = observer; /// Detaches the observer once and releases references to both endpoints. diff --git a/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs index a9063ac3..4e2f174a 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ProjectedReadOnlyState{TSource,TResult}.cs @@ -214,7 +214,7 @@ private void Remove(IObserver observer) } /// Throws if disposed. - /// The projection has already been disposed. + /// The projection is disposed. private void ThrowIfDisposed() { if (!_isDisposed) diff --git a/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs index 8fa1fd3b..906b0050 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/ReadOnlyState{T}.cs @@ -11,10 +11,10 @@ namespace ReactiveUI.Primitives.Signals; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ReadOnlyState : IObservable, IDisposable { - /// Stores state for the signal implementation. + /// The state signal holding the mirrored value and its subscribers. private readonly StateSignal _inner; - /// Stores state for the signal implementation. + /// The subscription feeding source values into the inner state. private readonly IDisposable _subscription; /// Initializes a new instance of the class. @@ -38,10 +38,9 @@ public ReadOnlyState(IObservable source, T initialValue) [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Notifies the provider that an observer is to receive notifications. - /// The object that is to receive notifications. - /// A reference to an interface that allows observers to stop receiving notifications before the provider has - /// finished sending them. + /// Subscribes an observer, which receives the current value followed by every later one. + /// The observer to subscribe. + /// A handle that unsubscribes the observer when disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable Subscribe(IObserver observer) => _inner.Subscribe(observer); diff --git a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs index b6dffb44..073f3bcf 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs @@ -9,11 +9,11 @@ namespace ReactiveUI.Primitives.Signals; /// A signal that is both an observer and observable of values. -/// The Type. +/// The value type. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public class Signal : ISignal { - /// Stores state for the signal implementation. + /// The number of slots the subscription array starts with. private const int InitialSubscriptionCapacity = 4; /// The factor the subscription array grows by when it fills. @@ -28,7 +28,7 @@ public class Signal : ISignal /// Serializes observer-set and terminal-state mutations; dispatch reads the published target without this gate. private readonly Lock _observerLock = new(); - /// Stores state for the signal implementation. + /// The terminal error, when the signal faulted. private Exception? _exception; /// Atomically published dispatch target: empty, one subscription, a slot array, or a terminal marker. @@ -37,19 +37,19 @@ public class Signal : ISignal /// The reusable slot array backing the multi-subscriber shape, kept across an empty period. private SignalSubscription?[]? _slots; - /// Stores state for the signal implementation. + /// The number of occupied slots in the slot array. private int _subscriptionCount; - /// Stores state for the signal implementation. + /// The exclusive upper bound of slots that have been handed out, so a scan for a free slot stops there. private int _subscriptionTail; - /// Stores state for the signal implementation. + /// Whether the signal has been disposed. private bool _isDisposed; - /// Stores state for the signal implementation. + /// Whether a terminal notification has been delivered. private bool _isStopped; - /// Gets a value indicating whether indicates whether the subject has observers subscribed to it. + /// Gets a value indicating whether any observer is subscribed to the signal. public virtual bool HasObservers { get @@ -60,7 +60,7 @@ public virtual bool HasObservers } } - /// Gets a value indicating whether indicates whether the subject has been disposed. + /// Gets a value indicating whether the signal has been disposed. public virtual bool IsDisposed => _isDisposed; /// Gets the debugger display text. @@ -68,14 +68,14 @@ public virtual bool HasObservers [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Releases unmanaged and - optionally - managed resources. + /// Detaches every subscription and makes later notifications throw. public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - /// Called when [completed]. + /// Completes the current observers and stops the signal, so later notifications are ignored. public void OnCompleted() { object? observers; @@ -95,8 +95,8 @@ public void OnCompleted() Completed(observers); } - /// Called when [error]. - /// The error. + /// Faults the current observers and stops the signal, rethrowing to the caller when a value-only callback is subscribed. + /// The terminal error. public void OnError(Exception error) { ArgumentExceptionHelper.ThrowIfNull(error); @@ -125,9 +125,9 @@ public void OnError(Exception error) ExceptionDispatchInfo.Capture(error).Throw(); } - /// Called when [next]. - /// The value. - /// Emission reads the published observer state without locking. Terminal signals ignore subsequent values; disposed signals throw. + /// Emits a value to the current observers; a stopped signal drops it and a disposed signal throws . + /// The value to emit. + /// Dispatch reads the published observer set without taking the lock, so concurrent calls are not serialized against each other. public void OnNext(T value) { var observers = Volatile.Read(ref _observers); @@ -151,11 +151,9 @@ public void OnNext(T value) throw Disposed(); } - /// Subscribes the specified observer. - /// The observer. - /// - /// A IDisposable. - /// + /// Subscribes an observer, delivering the stored terminal notification immediately when the signal has stopped. + /// The observer to subscribe. + /// A handle that detaches the observer when disposed. public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); @@ -193,9 +191,9 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - /// Registers a value callback, or rethrows the stored error if the signal has already failed. - /// The onNext value. - /// The result. + /// Registers a value-only callback, rethrowing the stored error when the signal has faulted. + /// The callback invoked for each value. + /// A handle that detaches the callback when disposed. public IDisposable SubscribeAction(Action onNext) { ArgumentExceptionHelper.ThrowIfNull(onNext); @@ -229,7 +227,7 @@ public IDisposable SubscribeAction(Action onNext) return EmptyDisposable.Instance; } - /// Releases unmanaged and - optionally - managed resources. + /// Publishes the disposed marker and detaches the subscriptions it replaced. /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { @@ -262,7 +260,7 @@ protected virtual void Dispose(bool disposing) private static ObjectDisposedException Disposed() => new(string.Empty); /// Forwards completion to each subscription in the captured observer snapshot. - /// The observer shape captured while the signal was still running. + /// The observer shape the terminal marker replaced. private static void Completed(object? observers) { if (observers is SignalSubscription single) @@ -283,8 +281,8 @@ private static void Completed(object? observers) } /// Forwards an error to each subscription in the captured observer snapshot. - /// The observer shape captured while the signal was still running. - /// The exception value. + /// The observer shape the terminal marker replaced. + /// The error to forward. private static void Error(object? observers, Exception exception) { if (observers is SignalSubscription single) @@ -305,8 +303,8 @@ private static void Error(object? observers, Exception exception) } /// Checks whether the captured snapshot contains any value-only callback subscriptions. - /// The observer shape captured while the signal was still running. - /// The result. + /// The observer shape the terminal marker replaced. + /// when at least one subscription holds a value-only callback. private static bool HasActionSubscribers(object? observers) { if (observers is SignalSubscription single) @@ -352,8 +350,8 @@ private static void DisposeSubscriptions(object? observers) } /// Forwards a value to each occupied subscription slot. - /// The subscription snapshot, which the observer field only ever holds non-null. - /// The value. + /// The slot array to walk. + /// The value to forward. private static void DispatchSubscriptions(SignalSubscription?[] subscriptions, T value) { for (var i = 0; i < subscriptions.Length; i++) @@ -370,7 +368,7 @@ private static void DispatchSubscriptions(SignalSubscription?[] subscriptions, T /// Dispatches to every live slot, then reports a disposal that raced the dispatch. /// The slot array this dispatch captured. - /// The value. + /// The value to forward. private void DispatchToSlots(SignalSubscription?[] subscriptions, T value) { DispatchSubscriptions(subscriptions, value); @@ -394,7 +392,7 @@ private void ThrowIfDisposed() } /// Adds a subscription and publishes the resulting observer shape. - /// The subscription value. + /// The subscription to add. private void AddSubscriptionLocked(SignalSubscription subscription) { if (_observers is null) @@ -415,8 +413,8 @@ private void AddSubscriptionLocked(SignalSubscription subscription) } /// Places a subscription in the reusable slot array, growing it when every slot is taken. - /// The subscription value. - /// The slot array the subscription now lives in. + /// The subscription to store. + /// The slot array holding the subscription. private SignalSubscription?[] AddToSlotsLocked(SignalSubscription subscription) { var slots = _slots; @@ -466,7 +464,7 @@ private void AddSubscriptionLocked(SignalSubscription subscription) } /// Removes a subscription while holding the observer lock. - /// The subscription value. + /// The subscription to remove. private void Remove(SignalSubscription subscription) { lock (_observerLock) @@ -487,7 +485,7 @@ private void Remove(SignalSubscription subscription) /// Removes a subscription from its owning slot array. /// The active slot array. - /// The subscription value. + /// The subscription to clear. private void RemoveFromSlotsLocked(SignalSubscription?[] slots, SignalSubscription subscription) { var index = Array.IndexOf(slots, subscription); @@ -516,7 +514,7 @@ private void ClearSlotLocked(SignalSubscription?[] slots, int index) Volatile.Write(ref _observers, null); } - /// Represents the SignalSubscription class. + /// One subscription's handle, holding either an observer or a value-only callback. private sealed class SignalSubscription : IDisposable { /// The observer target, or when this subscription stores an action callback. @@ -525,12 +523,12 @@ private sealed class SignalSubscription : IDisposable /// The action target, or when this subscription stores an observer. private readonly Action? _action; - /// Stores state for the signal implementation. + /// The owning signal, cleared by the first disposal. private Signal? _subject; /// Initializes a new instance of the class. - /// The subject value. - /// The observer value. + /// The owning signal. + /// The subscribed observer. public SignalSubscription(Signal subject, IObserver observer) { _subject = subject; @@ -538,8 +536,8 @@ public SignalSubscription(Signal subject, IObserver observer) } /// Initializes a new instance of the class. - /// The subject value. - /// The onNext value. + /// The owning signal. + /// The callback invoked for each value. public SignalSubscription(Signal subject, Action onNext) { _subject = subject; @@ -549,8 +547,8 @@ public SignalSubscription(Signal subject, Action onNext) /// Gets a value indicating whether this subscription stores an action callback. public bool IsAction => _action is not null; - /// Sends a value to the subscription target. - /// The value. + /// Sends a value to the observer or the callback, whichever this subscription holds. + /// The value to send. public void OnNext(T value) { var observer = _observer; @@ -563,12 +561,12 @@ public void OnNext(T value) _action!(value); } - /// Sends an error to observer subscriptions. - /// The exception. + /// Sends an error to the observer; a value-only callback receives nothing. + /// The error to send. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnError(Exception exception) => _observer?.OnError(exception); - /// Sends completion to observer subscriptions. + /// Sends completion to the observer; a value-only callback receives nothing. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => _observer?.OnCompleted(); diff --git a/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs b/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs index d6e7c243..9156e8b7 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs @@ -72,7 +72,7 @@ public void OnCompleted() } /// Forwards an error downstream. - /// The error value. + /// The terminal error. public void OnError(Exception error) { if (Interlocked.Exchange(ref _stopped, 1) != 0) diff --git a/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs b/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs index 77456a7c..a42bc13e 100644 --- a/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs +++ b/src/ReactiveUI.Primitives.Core/SubscribeExtensions.cs @@ -18,10 +18,10 @@ public static class SubscribeExtensions private static readonly Action nop = static () => { }; /// Exception helpers for a nullable exception receiver. - /// The exception. + /// The receiver exception, which may be . extension(Exception? exception) { - /// Rethrows Exception. + /// Rethrows the exception, doing nothing when there is none. public void Rethrow() { if (exception is null) @@ -38,9 +38,9 @@ public void Rethrow() /// Signals sequence to subscribe to. extension(IObservable source) { - /// Subscribes to the Signals sequence without specifying any handlers. This method can be used to evaluate the Signals sequence for its side-effects only. - /// object used to unsubscribe from the Signals sequence. - /// is null. + /// Subscribes without any handlers, so the source sequence runs for its side effects alone. + /// A handle that unsubscribes from the source sequence when disposed. + /// is null. public IDisposable Subscribe() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -48,9 +48,9 @@ public IDisposable Subscribe() return source.Subscribe(OnNextNoOpCache.Instance, nop); } - /// Subscribes to the Signals providing just the delegate. - /// The on next. - /// A IDisposable. + /// Subscribes a value callback; a terminal error is rethrown to the producer and completion is ignored. + /// The callback invoked for each value. + /// A handle that unsubscribes from the source sequence when disposed. public IDisposable Subscribe(Action onNext) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -65,10 +65,10 @@ public IDisposable Subscribe(Action onNext) }; } - /// Subscribes to the Signals providing both the and delegates. - /// The on next. - /// The on error. - /// A IDisposable. + /// Subscribes value and error callbacks; completion is ignored. + /// The callback invoked for each value. + /// The callback invoked with the terminal error. + /// A handle that unsubscribes from the source sequence when disposed. public IDisposable Subscribe(Action onNext, Action onError) { ArgumentExceptionHelper.ThrowIfNull(onError); @@ -76,10 +76,10 @@ public IDisposable Subscribe(Action onNext, Action onError) return source.Subscribe(onNext, onError, nop); } - /// Subscribes to the Signals providing both the and delegates. - /// The on next. - /// The on completed. - /// A IDisposable. + /// Subscribes value and completion callbacks; a terminal error is rethrown to the producer. + /// The callback invoked for each value. + /// The callback invoked when the sequence completes. + /// A handle that unsubscribes from the source sequence when disposed. public IDisposable Subscribe(Action onNext, Action onCompleted) { ArgumentExceptionHelper.ThrowIfNull(onCompleted); @@ -87,11 +87,11 @@ public IDisposable Subscribe(Action onNext, Action onCompleted) return source.Subscribe(onNext, rethrow, onCompleted); } - /// Subscribes to the Signals providing all three , and delegates. - /// The on next. - /// The on error. - /// The on completed. - /// A IDisposable. + /// Subscribes value, error, and completion callbacks. + /// The callback invoked for each value. + /// The callback invoked with the terminal error. + /// The callback invoked when the sequence completes. + /// A handle that unsubscribes from the source sequence when disposed. public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -107,39 +107,39 @@ public IDisposable Subscribe(Action onNext, Action onError, Action : source.Subscribe(new EmptyWitness(onNext, onError, onCompleted)); } - /// Subscribes to the Signals sequence without specifying any handlers using a Primitives-specific name. - /// object used to unsubscribe from the Signals sequence. - /// is null. + /// Subscribes without any handlers, under the Primitives-specific name. + /// A handle that unsubscribes from the source sequence when disposed. + /// is null. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable SubscribePrimitives() => Subscribe(source); - /// Subscribes to the Signals providing just the delegate using a Primitives-specific name. - /// The on next. - /// A IDisposable. + /// Subscribes a value callback, under the Primitives-specific name. + /// The callback invoked for each value. + /// A handle that unsubscribes from the source sequence when disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable SubscribePrimitives(Action onNext) => Subscribe(source, onNext); - /// Subscribes to the Signals providing next and error delegates using a Primitives-specific name. - /// The on next. - /// The on error. - /// A IDisposable. + /// Subscribes value and error callbacks, under the Primitives-specific name. + /// The callback invoked for each value. + /// The callback invoked with the terminal error. + /// A handle that unsubscribes from the source sequence when disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable SubscribePrimitives(Action onNext, Action onError) => Subscribe(source, onNext, onError); - /// Subscribes to the Signals providing next and completed delegates using a Primitives-specific name. - /// The on next. - /// The on completed. - /// A IDisposable. + /// Subscribes value and completion callbacks, under the Primitives-specific name. + /// The callback invoked for each value. + /// The callback invoked when the sequence completes. + /// A handle that unsubscribes from the source sequence when disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable SubscribePrimitives(Action onNext, Action onCompleted) => Subscribe(source, onNext, onCompleted); - /// Subscribes to the Signals providing all callback delegates using a Primitives-specific name. - /// The on next. - /// The on error. - /// The on completed. - /// A IDisposable. + /// Subscribes value, error, and completion callbacks, under the Primitives-specific name. + /// The callback invoked for each value. + /// The callback invoked with the terminal error. + /// The callback invoked when the sequence completes. + /// A handle that unsubscribes from the source sequence when disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable SubscribePrimitives(Action onNext, Action onError, Action onCompleted) => Subscribe(source, onNext, onError, onCompleted); diff --git a/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs b/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs index eb6c9e1c..a01e09c2 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs @@ -8,11 +8,10 @@ namespace ReactiveUI.Primitives.Extensions; /// -/// Limits the concurrency of task execution and emits results through an observable sequence. -/// Implements directly so the surface needs no -/// ActionDisposable closure wrappers; the per-task continuation state is the -/// per-subscription instance, which is already a reference type -/// and therefore needs no boxing through . +/// Drains a task sequence with at most tasks in flight, emitting each result as it +/// completes and completing once the sequence is exhausted. A faulted or cancelled task terminates the sequence with +/// that task's exception. Subscribers share one enumerator over , so a second +/// subscription continues draining where the first stopped, and disposing any subscription halts the drain for all. /// /// The type of the task results. /// The task functions to drain. @@ -26,20 +25,16 @@ public sealed class ConcurrencyLimiter(IEnumerable> taskFunctions, in /// The number of tasks currently in flight that have not yet completed. private int _outstanding; - /// Global disposal latch set by any . Preserves the - /// existing single-subscription-at-a-time semantics: once any consumer disposes, the limiter - /// stops pulling further tasks. + /// Disposal latch set by any subscription; once set, no further tasks are pulled. private int _disposed; - /// Lazy enumerator over the source task sequence; once exhausted. + /// Lazy enumerator over the source task sequence; when exhausted. private IEnumerator>? _rator; - /// Gets the observable sequence — the limiter is its own . + /// Gets this limiter as an observable sequence of task results. public IObservable Observable => this; - /// Gets or sets a value indicating whether the limiter has been disposed by any - /// consumer. Exposed to internal tests; production paths set it via - /// . + /// Gets or sets a value indicating whether any subscription has disposed the limiter. [SuppressMessage( "RoslynCommonAnalyzers", "SST2200:Replace this single-use backing field with the 'field' keyword", @@ -68,29 +63,27 @@ public IDisposable Subscribe(IObserver observer) return subscription; } - /// Clears the lazy enumerator. Caller must hold on the production paths; exposed to internal tests that exercise the idempotent-second-call branch. + /// Disposes and drops the task enumerator; callers must hold . internal void ClearRator() { _rator?.Dispose(); _rator = null; } - /// Test entry point that adapts a raw into a fresh - /// and pulls the next task. Production paths go through - /// which creates the subscription once. + /// Wraps the observer in a fresh subscription and pulls the next task for it. /// The observer that will receive notifications. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void PullNextTask(IObserver observer) => PullNextTask(new Subscription(this, observer)); - /// Processes the completion of a previously-scheduled task. + /// Delivers a finished task's result and pulls the next one, or terminates the sequence on failure. /// The owning subscription. /// The completed task. [SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", Justification = - "Task is guaranteed complete at this call site (IsFaulted/IsCanceled were both false above); reading .Result drives the synchronous IObserver contract without blocking.")] + "The task is complete at this call site, so reading Result does not block.")] internal void ProcessTaskCompletion(Subscription subscription, Task completed) { lock (_gate) @@ -151,7 +144,6 @@ private void PullNextTask(Subscription subscription) _outstanding++; - // The subscription carries continuation state without boxing or closure capture. _rator.Current?.ContinueWith( static (ant, state) => { @@ -165,7 +157,7 @@ private void PullNextTask(Subscription subscription) } } - /// Holds the observer and prevents notification delivery after disposal. + /// Pairs an observer with its limiter and reports the limiter's disposal state to the drain loop. /// The owning limiter. /// The downstream observer. internal sealed class Subscription(ConcurrencyLimiter limiter, IObserver observer) : IDisposable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs b/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs index 2782b64e..2296206c 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs @@ -6,11 +6,15 @@ namespace ReactiveUI.Primitives.Extensions; -/// Coordinates phase synchronization between a lock holder and its continuation. +/// +/// Pairs an emitted item with a release handle so a producer can wait on its consumer: hands the +/// item and this instance to an observer and returns a task that completes once that handle is disposed. The barrier +/// behind it takes two participants and is torn down by , so an instance gates one handoff. +/// [System.Diagnostics.DebuggerDisplay("Continuation: Locked = {_locked}, CompletedPhases = {CompletedPhases}")] public class Continuation : IDisposable { - /// The barrier used to synchronize phases between the lock holder and the continuation. + /// The two-participant barrier that synchronizes phases between the gate holder and its continuation. private readonly Barrier _phaseSync = new(2); /// One once this instance has been disposed; otherwise zero. @@ -19,25 +23,26 @@ public class Continuation : IDisposable /// One while the continuation is locked; otherwise zero. private int _locked; - /// Gets the number of completed phases. - /// - /// The completed phases. - /// + /// Gets the number of barrier phases that have completed. public long CompletedPhases => _phaseSync.CurrentPhaseNumber; - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Releases the gate, completing the task returned by , and tears down the barrier. public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - /// Locks this instance. + /// + /// Takes the gate and hands to paired with this instance as the + /// release handle. A call made while the gate is held emits nothing. + /// /// The type of the elements in the source sequence. - /// The item. - /// The observer. + /// The item handed to the observer. + /// The observer receiving the item and its release handle; ignored when . /// - /// A representing the asynchronous operation. + /// A that completes once the release handle is disposed, or a completed task when the gate was + /// held. /// public Task Lock(T item, IObserver<(T Value, IDisposable Sync)>? observer) { @@ -50,15 +55,11 @@ public Task Lock(T item, IObserver<(T Value, IDisposable Sync)>? observer) return ScheduleSignalPhase(); } - /// - /// -returning counterpart to . Use this at per-emission - /// call sites where the returned task is awaited exactly once — saves the boxed - /// wrapper allocation in the already-locked fast path. - /// + /// -returning counterpart to , for call sites that await the result exactly once. /// The type of the elements in the source sequence. - /// The item. - /// The observer. - /// A representing the asynchronous operation. + /// The item handed to the observer. + /// The observer receiving the item and its release handle; ignored when . + /// A that completes once the release handle is disposed, or a completed task when the gate was held. public ValueTask LockValueTask(T item, IObserver<(T Value, IDisposable Sync)>? observer) { if (Interlocked.Exchange(ref _locked, 1) != 0) @@ -70,8 +71,8 @@ public ValueTask LockValueTask(T item, IObserver<(T Value, IDisposable Sync)> return new(ScheduleSignalPhase()); } - /// UnLocks this instance. - /// A representing the asynchronous operation. + /// Releases the gate and signals the barrier phase; a no-op when the gate is not held. + /// A that completes once the barrier phase is signalled. internal Task UnLock() => Interlocked.Exchange(ref _locked, 0) == 0 ? Task.CompletedTask : ScheduleSignalPhase(); @@ -81,8 +82,7 @@ internal Task UnLock() => "Concurrency", "SST1905:Do not use async void", Justification = - "This is the Dispose(bool) disposal-pattern overload, whose signature is fixed to return void; it cannot return " - + "Task. The await is best-effort teardown of the phase barrier during disposal, with no caller positioned to observe it.")] + "The disposal-pattern overload must return void, and no caller is positioned to observe the awaited barrier teardown.")] protected virtual async void Dispose(bool disposing) { if (Interlocked.Exchange(ref _disposedValue, 1) != 0 || !disposing) @@ -94,14 +94,14 @@ protected virtual async void Dispose(bool disposing) _phaseSync.Dispose(); } - /// Static state-carrying signal callback; avoids the per-call closure allocation a captured lambda would produce. + /// Signals the phase barrier and waits there for the paired participant. /// The owning instance. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void SignalPhaseSync(object? state) => ((Continuation)state!)._phaseSync.SignalAndWait(CancellationToken.None); - /// Schedules an action on the default task scheduler. - /// The task representing the scheduled signal work. + /// Runs the barrier signal on the default task scheduler. + /// The task for the scheduled signal work. [MethodImpl(MethodImplOptions.AggressiveInlining)] private Task ScheduleSignalPhase() => Task.Factory.StartNew( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/FirstAsTaskHelper.cs b/src/ReactiveUI.Primitives.Extensions.Core/FirstAsTaskHelper.cs index 17cd70be..23040c12 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/FirstAsTaskHelper.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/FirstAsTaskHelper.cs @@ -4,14 +4,10 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Subscribes once and completes the returned with the first emitted value; -/// faults the task on source error or on empty completion. Combines the -/// and the IObserver into a single allocation per call. -/// +/// Bridges the first value of an observable to a , faulting on source error and on completion without a value. public static class FirstAsTaskHelper { - /// Subscribes and resolves a task with the first value. + /// Subscribes to and settles the returned task from its first notification, disposing the subscription at that point. /// The element type. /// The source observable. /// A task that completes with the first value, faults on error, or faults on empty completion. @@ -23,11 +19,11 @@ public static Task FirstAsTask(IObservable source) return observer.Task; } - /// Combined TaskCompletionSource + IObserver — one heap allocation per call instead of two. + /// Observer that settles its own task from the first notification it receives. /// The element type. private sealed class FirstWitness() : TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), IObserver { - /// Latches to 1 once the task has been settled so subsequent callbacks are no-ops. + /// Latches to 1 when the task is settled so later callbacks are no-ops. private int _settled; /// Gets or sets the source subscription so the first-value path can dispose it on completion. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs b/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs index 4449aa55..1b488707 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs @@ -8,14 +8,14 @@ namespace ReactiveUI.Primitives.Extensions; /// -/// -returning counterpart to . Backs the -/// ToHotValueTask extension with a pooled implementation -/// so steady-state callers pay zero allocations after the pool warms up. +/// -returning counterpart to . The returned value task is +/// backed by a pooled , so each one must be consumed exactly once: awaiting it twice, +/// or reading its result after the backing instance returns to the pool, observes another caller's outcome. /// /// The element type. public static class FirstAsValueTaskHelper { - /// Single-slot pool. null when the previous instance is in flight. + /// Single-slot pool holding the idle witness; while the witness is in flight. private static PooledFirstWitness? _pooled; /// Subscribes once and resolves a with the first value. @@ -29,13 +29,13 @@ public static ValueTask FirstAsValueTask(IObservable source) return inst.Begin(source); } - /// Pooled combined + . + /// Observer that settles a reusable value-task source from the first notification, then returns itself to the pool once the result is read. private sealed class PooledFirstWitness : IValueTaskSource, IObserver { /// The reset-able backing store for the machinery. private ManualResetValueTaskSourceCore _core = new() { RunContinuationsAsynchronously = true }; - /// Latches to 1 once the source has been settled so subsequent callbacks no-op. + /// Latches to 1 when the source is settled so later callbacks no-op. private int _settled; /// The upstream subscription, retained so can cancel it on first match. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs b/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs index 73784d70..a27b1f82 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs @@ -5,10 +5,9 @@ namespace ReactiveUI.Primitives.Extensions; /// -/// Represents either a heartbeat signal or a value update from an observable stream. Value-type shape; the -/// heartbeat operator emits these directly so per-emission allocations are zero. Note that -/// default(Heartbeat<T>) represents a value update with the default ; use -/// new Heartbeat<T>() to construct a heartbeat tick. +/// Carries either a heartbeat tick or a value update from an observable sequence. default(Heartbeat<T>) is a +/// value update holding the default ; construct a heartbeat tick with +/// new Heartbeat<T>(). /// /// The type of the update value. [System.Diagnostics.DebuggerDisplay("Heartbeat: IsHeartbeat = {IsHeartbeat}, Update = {Update}")] diff --git a/src/ReactiveUI.Primitives.Extensions.Core/IDrainTarget.cs b/src/ReactiveUI.Primitives.Extensions.Core/IDrainTarget.cs index 25033338..3b7c066c 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/IDrainTarget.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/IDrainTarget.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Implemented by sinks that drive a ScheduledDrainState<T>. The state helper invokes -/// once per scheduled burst via a static scheduler callback, so passing the sink -/// as an keeps the scheduled action allocation-free (no captured closure). -/// +/// Receives the drain callback a ScheduledDrainState<T> raises once per scheduled pass. public interface IDrainTarget { /// Drains the queued notifications on the scheduler thread. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/IHeartbeat.cs b/src/ReactiveUI.Primitives.Extensions.Core/IHeartbeat.cs index f9c2b795..f5bb80be 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/IHeartbeat.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/IHeartbeat.cs @@ -4,15 +4,13 @@ namespace ReactiveUI.Primitives.Extensions; -/// Heart beat. -/// The type. +/// Carries either a heartbeat tick or a value update from an observable sequence. +/// The type of the update value. public interface IHeartbeat { - /// Gets a value indicating whether this instance is heartbeat. - /// true if this instance is heartbeat; otherwise, false. + /// Gets a value indicating whether this notification is a heartbeat tick rather than an update. bool IsHeartbeat { get; } - /// Gets the update. - /// The update. + /// Gets the update value, which carries no meaning while is . T? Update { get; } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/IStale.cs b/src/ReactiveUI.Primitives.Extensions.Core/IStale.cs index 3bdb4f49..285754a6 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/IStale.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/IStale.cs @@ -4,15 +4,13 @@ namespace ReactiveUI.Primitives.Extensions; -/// Indicator for connection that has become stale. -/// The type. +/// Carries either a staleness signal or a value update from an observable sequence. +/// The type of the update value. public interface IStale { - /// Gets a value indicating whether this instance is stale. - /// true if this instance is stale; otherwise, false. + /// Gets a value indicating whether this notification signals staleness rather than an update. bool IsStale { get; } - /// Gets the update. - /// The update. + /// Gets the update value; an implementation may reject the read while is . T? Update { get; } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs index 876b034d..a27c2f06 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ConcurrencyRaceHelpers.cs @@ -7,27 +7,17 @@ namespace ReactiveUI.Primitives.Extensions.Internal; /// Claims one-time transitions and tolerates cancellation of disposed token sources. internal static class ConcurrencyRaceHelpers { - /// - /// Atomically transitions from - /// to . Returns if this caller - /// won the race; if another caller had already claimed the state. - /// + /// Atomically moves from to . /// The reference to the state field. - /// The sentinel value the state must currently hold. + /// The sentinel value the state must hold for the claim to succeed. /// The sentinel value the state transitions to on success. - /// - /// if the claim succeeded; if another caller - /// already claimed the state. - /// + /// when this caller made the transition; when another caller holds the claim. internal static bool TryClaim(ref int state, int openSentinel, int claimedSentinel) => Interlocked.CompareExchange(ref state, claimedSentinel, openSentinel) == openSentinel; /// Cancels the source asynchronously, tolerating concurrent disposal. /// The cancellation token source to cancel. - /// - /// if the cancellation completed; if the - /// source was already disposed. - /// + /// when the cancellation ran; when the source was disposed first. internal static async ValueTask TryCancelAsync(CancellationTokenSource cts) { try diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs index f82aca30..597d433e 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/IndexedSubscribeHelper.cs @@ -37,7 +37,7 @@ internal static IDisposable SubscribeIndexed( return composite; } - /// Observer that carries a source index without allocating per-source callback closures. + /// Observer that tags each notification with its source index before invoking the shared hooks. /// The element type. /// The source index. /// Per-source OnNext hook. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs index 921ca609..3b44768f 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ObservableSubscribeExtensions.cs @@ -14,7 +14,7 @@ internal static class ObservableSubscribeExtensions /// The source observable. extension(IObservable source) { - /// Subscribes delegate callbacks through the core sink; the distinct name avoids ambiguity with System.Reactive extensions. + /// Subscribes the three delegate callbacks to the source through the core sink. /// Per-value callback. /// Error callback. /// Completion callback. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs index bb3123fc..8e26104b 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs @@ -5,8 +5,7 @@ namespace ReactiveUI.Primitives.Extensions.Internal; /// Stores synchronized reduction state and coordinates terminal notifications. -/// The source element type (must be a struct so TIn? doubles as the -/// "value seen yet?" Optional). +/// The source element type; the struct constraint lets TIn? record whether a value has arrived. /// The downstream element type the operator emits after reducing. internal sealed class ReduceSinkState where TIn : struct diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Observables.cs b/src/ReactiveUI.Primitives.Extensions.Core/Observables.cs index a5c933d9..fb8bf400 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Observables.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Observables.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Factory methods that build instances. The plural name avoids -/// resolution collisions with other observable factory types at call sites that import multiple -/// reactive namespaces. -/// +/// Factory methods that build sequences. public static class Observables { /// Returns an observable sequence that emits a single value and completes synchronously inside . diff --git a/src/ReactiveUI.Primitives.Extensions.Core/ObserverArrayHelpers.cs b/src/ReactiveUI.Primitives.Extensions.Core/ObserverArrayHelpers.cs index 46cda195..d0fe7ccd 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/ObserverArrayHelpers.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/ObserverArrayHelpers.cs @@ -4,19 +4,10 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// 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 RxVoid-testable through this class. -/// +/// Helpers for broadcasting to, and removing from, swap-on-write arrays. public 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). - /// + /// Fans out to every observer in in order; an empty array emits nothing. /// The value type. /// The observer array snapshot. /// The value to broadcast. @@ -33,18 +24,14 @@ public static void Broadcast(IObserver[] observers, T 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). - /// + /// Copies without , reporting absence so a caller can skip the swap entirely. /// 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. + /// The shortened array, when the removed observer was the last one, or + /// when the observer is absent from . /// public static IObserver[]? RemoveOrNull( IObserver[] current, diff --git a/src/ReactiveUI.Primitives.Extensions.Core/ObserverExtensions.cs b/src/ReactiveUI.Primitives.Extensions.Core/ObserverExtensions.cs index bb905bd3..325e2cfa 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/ObserverExtensions.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/ObserverExtensions.cs @@ -13,11 +13,9 @@ public static class ObserverExtensions extension(IObserver observer) { /// - /// Emits each element from to the observer - /// via using a fast-path iteration for known - /// concrete collection types (, arrays, ). - /// Avoids the per-iteration enumerator allocation that foreach over a bare - /// would incur on these common shapes. + /// Emits every element of to the observer in order through + /// , indexing arrays, and the list interfaces directly + /// and enumerating anything else. /// /// The source collection to iterate. public void FastForEach(IEnumerable source) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs index 50adafc2..15247b6f 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs @@ -7,7 +7,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Fast path for the common two-source min/max case. +/// +/// Emits the larger or smaller of the latest values from two sources, starting once both have produced one. An error +/// from either source terminates the sequence; completion waits for both sources, or fires as soon as one completes +/// without having emitted. +/// /// The value type. /// The first source. /// The second source. @@ -62,7 +66,7 @@ private sealed class Sink(IObserver downstream, bool emitMaximum) /// Whether the sink is terminal. private bool _isDone; - /// Handles a source value. + /// Records one side's latest value and emits the winning comparison once both sides have a value. /// true for the left source. /// The value. public void OnNext(bool isLeft, T value) @@ -96,7 +100,7 @@ public void OnNext(bool isLeft, T value) } } - /// Handles a source error. + /// Forwards the first error downstream and marks the sink terminal. /// The error. public void OnError(Exception error) { @@ -112,7 +116,7 @@ public void OnError(Exception error) } } - /// Handles source completion. + /// Records one side's completion, completing downstream when both sides finish or when this side never emitted. /// true for the left source. public void OnCompleted(bool isLeft) { @@ -159,7 +163,7 @@ public void OnCompleted(bool isLeft) } } - /// Completes once. + /// Marks the sink terminal and completes the downstream observer. private void Complete() { _isDone = true; @@ -167,7 +171,7 @@ private void Complete() } } - /// Observer that labels left/right without per-callback closures. + /// Observer that forwards notifications to the shared sink tagged with the side it came from. /// The shared sink. /// true when observing the left source. private sealed class IndexedWitness(Sink sink, bool isLeft) : IObserver diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs index eb36b262..24cd7c7c 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs @@ -9,10 +9,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// -/// Combines the latest boolean values from multiple sources and emits true iff every latest -/// value equals . Backs both AllTrue (target=true) and AllFalse -/// (target=false) without the array allocations a generic CombineLatest(...).Select(xs => xs.All(...)) -/// pipeline would incur. +/// Emits whether every source's latest boolean equals , re-evaluating on each value once all +/// sources have produced one. An empty source list emits true and completes on subscribe; any source's error +/// terminates the sequence, and the sequence completes when every source completes or one completes without emitting. /// /// The source observables. /// The value every source must hold for the operator to emit true. @@ -44,7 +43,7 @@ public IDisposable Subscribe(IObserver observer) return IndexedSubscribeHelper.SubscribeIndexed(_sourceList, sink.OnNext, sink.OnError, sink.OnCompleted); } - /// Materializes source enumeration once without using LINQ in shipping code. + /// Copies the sources into an indexable list, enumerating the sequence at most once. /// The sources to materialize. /// The source list. private static IReadOnlyList> MaterializeSources(IEnumerable> sources) @@ -94,7 +93,7 @@ private static IReadOnlyList> MaterializeSources(IEnumerableThe value every source must hold for emit to be true. private sealed class Sink(IObserver downstream, int count, bool target) { - /// Shared gate / value cache / terminal-state plumbing. + /// The shared gate, per-source value cache and terminal-state bookkeeping. private readonly ReduceSinkState _state = new(downstream, count); /// Reduces the per-source latest values to whether every source holds the target value. @@ -111,18 +110,18 @@ private sealed class Sink(IObserver downstream, int count, bool target) return true; }; - /// Handles OnNext from a source. + /// Records one source's latest value and re-evaluates the combined result. /// Source index. /// Emitted value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(int index, bool value) => _state.HandleNext(index, value, _reduce); - /// Handles OnError from any source. + /// Forwards an error from any source downstream and terminates the sink. /// The error. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnError(Exception error) => _state.HandleError(error); - /// Handles OnCompleted from a source. + /// Records one source's completion and completes downstream when the sequence is finished. /// Source index. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted(int index) => _state.HandleCompleted(index); diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs index 278b0a74..13f90a05 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs @@ -7,7 +7,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Operator that buffers characters until a start and end delimiter are found. +/// +/// Buffers characters from through inclusive and emits each +/// delimited run as one string, discarding characters outside a run. Completion flushes a run whose end delimiter never +/// arrived. +/// /// The source observable of characters. /// The starting delimiter. /// The ending delimiter. @@ -33,10 +37,10 @@ private sealed class BufferUntilWitness( char startsWith, char endsWith) : IObserver { - /// The string builder. + /// Accumulates the characters of the run being buffered. private readonly StringBuilder _sb = new(); - /// Whether the start delimiter has been found. + /// Set while a run is open, meaning the start delimiter has arrived and the end delimiter has not. private bool _startFound; /// diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs index 137c4edb..f132df7d 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs @@ -6,7 +6,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Catches the configured exception type, emits a fallback built from the exception, and completes. Other exception types propagate downstream. +/// +/// Catches , emits a fallback built from it, and completes. Other exception types +/// propagate unchanged, and an exception thrown by terminates the sequence in place +/// of the caught one. +/// /// Element type. /// Exception type to catch. /// Upstream source. @@ -25,11 +29,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new CatchAndReturnWithFactoryWitness(observer, fallbackFactory)); } - /// - /// Forwarding observer that passes / - /// through and converts a matching into an inline emit of the - /// factory-produced fallback followed by terminal . - /// + /// Forwarding observer that turns a matching error into the factory's fallback value followed by completion. /// The downstream observer. /// The fallback factory. private sealed class CatchAndReturnWithFactoryWitness( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreEmptyObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreEmptyObservable.cs index 05a87fde..a51d1d62 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreEmptyObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreEmptyObservable.cs @@ -37,9 +37,7 @@ private sealed class CatchIgnoreEmptyWitness(IObserver downstream) : IObserve "Design", "SST2318:Members should not have identical bodies", Justification = - "This witness deliberately swaps a terminal error for downstream completion, so OnError and OnCompleted " - + "both forward to downstream.OnCompleted by design. They are distinct IObserver channels, not a copy " - + "that was meant to differ.")] + "Swapping a terminal error for completion makes both observer channels forward to the same downstream call.")] public void OnCompleted() => downstream.OnCompleted(); } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs index c7b3d9e7..02e171cf 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs @@ -6,7 +6,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Operator that catches exceptions of a specific type and completes. +/// +/// Catches , hands it to and completes. Other exception +/// types propagate unchanged, and an exception thrown by the action terminates the sequence in place of the caught one. +/// /// The type of the elements in the source sequence. /// The type of the exception to catch. /// The source observable sequence. @@ -30,7 +33,7 @@ public IDisposable Subscribe(IObserver observer) return _source.Subscribe(new CatchIgnoreWitness(observer, _errorAction)); } - /// Observer that catches specific exceptions. + /// Forwarding observer that diverts a matching error into the action and completes instead. /// The downstream observer. /// The error action. private sealed class CatchIgnoreWitness( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchReturnObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchReturnObservable.cs index 523993b8..5d9282c1 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchReturnObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchReturnObservable.cs @@ -20,11 +20,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new CatchReturnWitness(observer, fallback)); } - /// - /// Forwarding observer that passes / - /// through and replaces with an inline emit of the stored - /// fallback followed by terminal . - /// + /// Forwarding observer that replaces any error with the stored fallback value followed by completion. /// The downstream observer receiving the forwarded signals. /// The fallback value to emit when the source errors. private sealed class CatchReturnWitness(IObserver downstream, T fallback) : IObserver diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs index 93d4ba87..95e69f0b 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnDisposeObservable.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Executes an action when the subscription is disposed. +/// Forwards the source and runs an action exactly once when the subscription is disposed, after the upstream subscription is released. /// The type of elements in the source sequence. /// The source observable. /// The action to execute when the subscription is disposed. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnSubscribeObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnSubscribeObservable.cs index d9384e82..eea92481 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnSubscribeObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DoOnSubscribeObservable.cs @@ -4,7 +4,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Executes an action at subscription time. +/// Runs an action before subscribing the observer to the source, so an exception from the action propagates out of Subscribe. /// The type of elements in the source sequence. /// The source observable. /// The action to execute when subscribed. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs index 8f18f0ee..0a31b029 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs @@ -6,7 +6,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Operator that drops source elements while an asynchronous action is in progress. Replaces the closure-based implementation in ReactiveExtensions.DropIfBusy. +/// +/// Runs for each source value and forwards the value once it finishes, dropping every +/// value that arrives while an action is in flight. An exception from the action terminates the sequence. +/// /// The element type. /// The source observable. /// The asynchronous action to execute for each forwarded element. @@ -60,7 +63,7 @@ public void OnCompleted() [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Dispose() => Volatile.Write(ref _done, 1); - /// Processes a value and returns its active operation. + /// Starts the action for the value when the sink is idle, and drops the value otherwise. /// The source value. /// The processing task, or a completed task when no work starts. internal Task OnNextAsync(T value) @@ -70,13 +73,12 @@ internal Task OnNextAsync(T value) return Task.CompletedTask; } - // If we can transition from 0 to 1, we handle this value. return Interlocked.CompareExchange(ref _isBusy, 1, 0) != 0 ? Task.CompletedTask : ProcessAsync(value); } - /// Executes the async action and manages the busy state transition. + /// Awaits the action, forwards the value unless the sink has terminated, and clears the busy flag. /// The value to process. /// A task representing the async operation. private async Task ProcessAsync(T value) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs index c6af7913..80bbdab0 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs @@ -7,7 +7,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Filters strings using a regular expression and forwards matching errors to the observer. +/// +/// Forwards the source strings that matches, dropping non-matches and nulls. An exception +/// raised while matching, such as a regex timeout, terminates the sequence. +/// /// The source observable emitting strings. /// The regex to use for filtering. public sealed class FilterRegexObservable( @@ -23,7 +26,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new FilterRegexWitness(observer, regex)); } - /// Observer that filters strings using regex. + /// Observer that forwards matching strings and turns a failure raised by the match into an error. /// The downstream observer receiving strings that match the regex. /// The regex used for filtering. private sealed class FilterRegexWitness( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs index 2fd49d88..114dc5f1 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs @@ -6,7 +6,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Emits the latest value from the source sequence or a default value if no value has been emitted. +/// +/// Emits on subscribe, then each source value that differs from the one emitted before +/// it under . +/// /// The type of elements in the source sequence. /// The source observable. /// The value to emit initially. @@ -25,7 +28,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(sink); } - /// Sink that implements the latest or default logic. + /// Observer that seeds the downstream with the default value and suppresses repeats of the last value. /// The observer to forward elements to. /// The value to emit initially. private sealed class LatestOrDefaultSink(IObserver downstream, T defaultValue) : IObserver @@ -36,7 +39,7 @@ private sealed class LatestOrDefaultSink(IObserver downstream, T defaultValue /// Whether any value has been emitted yet. private bool _hasEmitted; - /// Initializes the sink by emitting the default value. + /// Emits the seed value downstream, which the caller does before subscribing the source. public void Initialize() { downstream.OnNext(_last!); diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs index f581d7d2..072f4781 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs @@ -9,10 +9,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// -/// Combines the latest values from multiple sources and emits either the maximum or minimum on each -/// tick. Backs both the Max (=true) and Min -/// (=false) operators without the array allocations a generic -/// CombineLatest(...).Select(xs => xs.Max()) pipeline would incur. +/// Emits the maximum or minimum of the latest values from every source, re-evaluating on each value once all sources +/// have produced one. An empty source list completes on subscribe; any source's error terminates the sequence, and the +/// sequence completes when every source completes or one completes without emitting. /// /// The value type. /// The source observables. @@ -39,13 +38,13 @@ public IDisposable Subscribe(IObserver observer) return IndexedSubscribeHelper.SubscribeIndexed(_sourceList, sink.OnNext, sink.OnError, sink.OnCompleted); } - /// Sink that holds the latest value per source and emits either the max or the min. Composes for the shared plumbing. + /// Sink that holds the latest value per source and emits the maximum or the minimum of them. /// The downstream observer. /// The number of sources. /// true for max; false for min. private sealed class Sink(IObserver downstream, int count, bool emitMaximum) { - /// Shared gate / value cache / terminal-state plumbing. + /// The shared gate, per-source value cache and terminal-state bookkeeping. private readonly ReduceSinkState _state = new(downstream, count); /// Reduces the per-source latest values to the maximum or minimum. @@ -65,18 +64,18 @@ private sealed class Sink(IObserver downstream, int count, bool emitMaximum) return result; }; - /// Handles OnNext from a source. + /// Records one source's latest value and re-evaluates the emitted extreme. /// Source index. /// Emitted value. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnNext(int index, T value) => _state.HandleNext(index, value, _reduce); - /// Handles OnError from any source. + /// Forwards an error from any source downstream and terminates the sink. /// The error. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnError(Exception error) => _state.HandleError(error); - /// Handles OnCompleted from a source. + /// Records one source's completion and completes downstream when the sequence is finished. /// Source index. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted(int index) => _state.HandleCompleted(index); diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/NotObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/NotObservable.cs index f330df4c..b2230a39 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/NotObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/NotObservable.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Boolean negation operator. Replaces the source.Select(b => !b) -/// pattern with a dedicated forwarding observer, avoiding the per-subscription -/// closure allocation that the projection lambda would otherwise capture. -/// +/// Emits each source boolean negated, passing errors and completion through unchanged. /// The boolean source observable. public sealed class NotObservable(IObservable source) : IObservable { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PairwiseObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PairwiseObservable.cs index 503641f5..8677c29b 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PairwiseObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PairwiseObservable.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Emits (previous, current) pairs from a sequence. +/// Emits each adjacent pair of source values as (Previous, Current), so the first value produces nothing on its own. /// The type of elements in the source sequence. /// The source observable. public sealed class PairwiseObservable(IObservable source) : IObservable<(T Previous, T Current)> @@ -20,7 +20,7 @@ public IDisposable Subscribe(IObserver<(T Previous, T Current)> observer) return source.Subscribe(new PairwiseWitness(observer)); } - /// The observer for the pairwise operator. + /// Observer that holds the last value under a gate and pairs it with the next one. /// The downstream observer. private sealed class PairwiseWitness(IObserver<(T Previous, T Current)> downstream) : IObserver { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs index d5570e6c..6efebca2 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs @@ -8,7 +8,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Partitions a sequence into two observables based on a predicate. +/// +/// Splits a sequence into a and a side by predicate. Both sides share one +/// subscription to the source, opened when the first side is subscribed and released when the last subscription is +/// disposed; each value reaches one side only, while an error or completion reaches both. +/// /// The type of elements in the source sequence. [System.Diagnostics.DebuggerDisplay("PartitionObservable: Source = {_source}, Subscriptions = {_subscriptionCount}")] public sealed class PartitionObservable @@ -25,10 +29,10 @@ public sealed class PartitionObservable /// The source subscription. private IDisposable? _sourceSubscription; - /// The observer for the source. + /// The shared source observer, live only while at least one side is subscribed. private PartitionSink? _sink; - /// The number of subscriptions. + /// The number of live subscriptions across both sides. private int _subscriptionCount; /// Initializes a new instance of the class. @@ -107,8 +111,7 @@ public void Dispose() _parent._subscriptionCount--; if (_parent._subscriptionCount == 0) { - // Subscribe set _sourceSubscription alongside _sink under the same lock, - // so when the last branch disposes here it is non-null by construction. + // Set alongside the sink under this same lock, so the last disposal finds it non-null. _parent._sourceSubscription!.Dispose(); _parent._sourceSubscription = null; _parent._sink = null; @@ -127,7 +130,7 @@ private sealed class PartitionSide(PartitionObservable parent, bool side) : I public IDisposable Subscribe(IObserver observer) => parent.Subscribe(observer, side); } - /// Sink that partitions elements. + /// Observer that fans each value to the observers on the matching side. /// The parent observable. private sealed class PartitionSink(PartitionObservable parent) : IObserver { @@ -137,7 +140,7 @@ private sealed class PartitionSink(PartitionObservable parent) : IObserver /// The observers for the false side. private IObserver[] _falseObservers = []; - /// Adds an observer to the specified side. + /// Adds an observer to one side's array; callers hold the parent gate. /// The observer to add. /// The side. public void Add(IObserver observer, bool side) @@ -152,7 +155,7 @@ public void Add(IObserver observer, bool side) } } - /// Removes an observer from the specified side. + /// Removes an observer from one side's array; callers hold the parent gate. /// The observer to remove. /// The side. public void Remove(IObserver observer, bool side) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs index 7db5bebc..2aaf0a98 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PropertyChangedObservable.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// The property element type. /// The owning instance. /// The property name to filter by. -/// The compiled property getter (compiled once at construction). +/// Reads the property value from the owning instance. public sealed class PropertyChangedObservable( T source, string propertyName, @@ -33,11 +33,7 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// - /// Sink that holds the bound and detaches - /// it on dispose. Filters by property name inline and pulls the value through the - /// pre-compiled getter. - /// + /// Sink that owns the bound , filters it by property name, and detaches it on dispose. private sealed class PropertyChangedSink : IDisposable { /// The downstream observer receiving filtered property values. @@ -49,17 +45,17 @@ private sealed class PropertyChangedSink : IDisposable /// The property name to filter on. private readonly string _propertyName; - /// The pre-compiled property getter. + /// Reads the property value from the owning instance. private readonly Func _getter; - /// Disposed flag (0 = active, 1 = disposed). Updated lock-free. + /// Disposal latch: 0 while the handler is attached, 1 once detached. private int _disposed; /// Initializes a new instance of the class. /// The downstream observer. /// The owning instance. /// The property name to filter on. - /// The pre-compiled property getter. + /// Reads the property value from the owning instance. public PropertyChangedSink( IObserver downstream, T source, @@ -87,7 +83,7 @@ public void Dispose() _source.PropertyChanged -= Handler; } - /// Bound handler — filters by property name then forwards the pre-compiled getter result downstream. + /// Forwards a freshly read value for the watched property, turning a getter failure into an error that also detaches the handler. /// Event sender (unused). /// Event payload carrying the changed property name. private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/RetryForeverObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/RetryForeverObservable.cs index 96f2739b..a34c94e3 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/RetryForeverObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/RetryForeverObservable.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Re-subscribes to the source indefinitely on error. Forwards values verbatim and completes when -/// the source completes. Per-subscription state is held in a single sink; resubscription swaps the -/// inner disposable rather than allocating a new wrapper chain. -/// +/// Re-subscribes to the source indefinitely whenever it errors, discarding the error; values pass through and the source's completion ends the sequence. /// Element type. /// Upstream source. public sealed class RetryForeverObservable(IObservable source) : IObservable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs index def0c048..27dcfb83 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs @@ -7,7 +7,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Samples the latest value from the source observable whenever a trigger observable emits. +/// +/// Emits the source's latest value each time fires, repeating it when no newer value has +/// arrived and emitting nothing until the source produces its first. An error from either sequence terminates the +/// result; the source's completion completes it, while the trigger's completion is ignored. +/// /// The type of elements in the source sequence. /// The source observable. /// The trigger observable. @@ -28,7 +32,7 @@ public IDisposable Subscribe(IObserver observer) return new DisposableBag(sourceSub, triggerSub, sink); } - /// Sinks the source observable and samples it based on the trigger. + /// Holds the latest source value and the terminal state shared by the source and trigger observers. /// The downstream observer. private sealed class SampleLatestSink(IObserver downstream) : IDisposable { @@ -44,10 +48,10 @@ private sealed class SampleLatestSink(IObserver downstream) : IDisposable /// Whether the sequence is done. private bool _done; - /// Gets the source observer. + /// Gets a new observer that records source values into this sink. public IObserver SourceWitness => new SourceSampleWitness(this); - /// Gets the trigger observer. + /// Gets a new observer that samples this sink on each trigger notification. public IObserver TriggerObserver => new TriggerSampleWitness(this); /// @@ -86,7 +90,7 @@ private void OnAnyError(Exception error) } } - /// Forwards source completion. + /// Completes the downstream observer once, ignoring later notifications. private void OnSourceCompleted() { lock (_gate) @@ -120,7 +124,7 @@ private void OnTriggerNext() downstream.OnNext(value!); } - /// Observer for source values. + /// Observer that stores each source value in the sink. /// The owning sink. private sealed class SourceSampleWitness(SampleLatestSink sink) : IObserver { @@ -137,7 +141,7 @@ private sealed class SourceSampleWitness(SampleLatestSink sink) : IObserver public void OnCompleted() => sink.OnSourceCompleted(); } - /// Observer for trigger values. + /// Observer that asks the sink to emit its latest value on each trigger notification. /// The owning sink. private sealed class TriggerSampleWitness(SampleLatestSink sink) : IObserver { @@ -152,7 +156,7 @@ private sealed class TriggerSampleWitness(SampleLatestSink sink) : IObserver public void OnCompleted() { - /* Trigger completion does not affect sample */ + // A completed trigger leaves the sampled sequence running. } } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs index 8bd966cc..ed7f452b 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs @@ -6,7 +6,10 @@ namespace ReactiveUI.Primitives.Extensions; -/// Scans the source sequence and emits the initial value immediately upon subscription. +/// +/// Emits on subscribe, then the running accumulation after each source element. An exception +/// thrown by terminates the sequence and the accumulation stops there. +/// /// The type of elements in the source sequence. /// The type of the accumulated value. /// The source observable. @@ -29,7 +32,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(sink); } - /// Sink that implements the scan with initial logic. + /// Observer that keeps the running accumulation under a gate and emits it after each element. /// The observer to forward elements to. /// The initial accumulated value. /// The accumulator function. @@ -47,7 +50,7 @@ private sealed class ScanWithInitialSink( /// Whether the sink has finished. private bool _done; - /// Initializes the sink by emitting the initial value. + /// Emits the seed accumulation downstream, which the caller does before subscribing the source. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Initialize() => downstream.OnNext(_current); diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs index ed6d5d89..2e87fd00 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs @@ -5,7 +5,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Projects each element to an asynchronous operation with limited concurrency. +/// +/// Projects each element through an asynchronous selector with at most operations in +/// flight and queues the rest, so results arrive in completion order rather than source order. The first selector +/// failure terminates the sequence, and the source's completion is held back until the queue drains. +/// /// The type of elements in the source sequence. /// The type of the result of the asynchronous operation. /// The source observable. @@ -91,7 +95,7 @@ public void Dispose() } } - /// Processes a value and returns its active operation. + /// Queues the value and starts as many operations as the concurrency limit allows. /// The source value. /// The processing task, or a completed task when no work starts. internal Task OnNextAsync(TSource value) @@ -126,7 +130,7 @@ private Task TryProcessNext() return processing; } - /// Processes the async operation. + /// Awaits the selector, emits its result, then starts queued work or completes the sequence. /// The value to project. /// A task representing the operation. private async Task ProcessAsync(TSource value) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs index dbe6a704..cee3b71a 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs @@ -5,7 +5,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Projects each element to an asynchronous operation, preserving order and handling sequential execution. +/// +/// Projects each element through an asynchronous selector one operation at a time, queueing values that arrive while an +/// operation runs so results keep source order. The first selector failure terminates the sequence, and the source's +/// completion waits for the queue to drain. +/// /// The type of elements in the source sequence. /// The type of the result of the asynchronous operation. /// The source observable. @@ -89,7 +93,7 @@ public void Dispose() } } - /// Processes a value and returns its active operation. + /// Queues the value and starts the drain loop when no operation is running. /// The source value. /// The processing task, or a completed task when no work starts. internal Task OnNextAsync(TSource value) @@ -113,7 +117,7 @@ internal Task OnNextAsync(TSource value) return processing; } - /// Processes the next value in the queue. + /// Projects queued values one at a time, completing the sequence when the queue empties after the source finishes. /// A task representing the operation. private async Task ProcessNextAsync() { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs index 13e4b11d..47ad790a 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Projection operator that ignores every source element and emits a stored constant -/// instead. Replaces the common .Select(_ => value) pattern, avoiding the -/// per-subscription closure allocation that the lambda _ => value would -/// capture. -/// +/// Emits once per source element, ignoring the element itself; errors and completion pass through unchanged. /// The source element type (ignored). /// The result element type emitted to the downstream observer. /// The source observable whose values are ignored. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs index 3b43bb5c..219328db 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs @@ -5,7 +5,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Projects each element to an asynchronous operation, but only the result of the latest operation is emitted. +/// +/// Projects each element through an asynchronous selector and emits only the latest projection's result. A superseded +/// operation keeps running but its result and its failure are both discarded, and the source's completion is deferred +/// until the latest projection finishes. +/// /// The type of elements in the source sequence. /// The type of the result of the asynchronous operation. /// The source observable. @@ -31,7 +35,7 @@ internal sealed class SelectLatestAsyncSink(IObserver downstream, Func< /// The gate for state access. private readonly Lock _gate = new(); - /// The current operation ID to track latest. + /// Identifier of the most recent projection; a result carrying an older identifier is dropped. private long _currentId; /// Whether the source has completed (no more values will arrive). @@ -40,7 +44,7 @@ internal sealed class SelectLatestAsyncSink(IObserver downstream, Func< /// Whether downstream completion has been signalled. private bool _completionSignalled; - /// The latest in-flight projection task, used to delay completion until it finishes. + /// The latest in-flight projection task, whose completion gates the downstream completion. private Task? _latestTask; /// Whether the sink has been disposed. @@ -99,7 +103,7 @@ public void Dispose() } } - /// Processes a value and returns its active operation. + /// Starts a projection for the value and records it as the latest, superseding any in-flight one. /// The source value. /// The processing task, or a completed task when no work starts. internal Task OnNextAsync(TSource value) @@ -139,7 +143,7 @@ internal void SignalCompleted() } } - /// Processes the async operation and checks for latest ID. + /// Awaits the selector and emits or faults only while this operation is the latest one. /// The value to project. /// The ID of this operation. /// A task representing the operation. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs index 98f0b109..222b2a3e 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs @@ -6,7 +6,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Chains two one-shot observable projections and forwards errors from either stage. +/// +/// Projects each source element through and each intermediate element through +/// , emitting the second stage's values. An exception from either projection terminates the +/// sequence, and the first inner sequence to complete completes the result. +/// /// The source element type. /// The intermediate element type produced by the first projection. /// The final element type produced by the second projection. @@ -28,7 +32,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new SourceWitness(observer, first, second)); } - /// Subscribes the reusable intermediate observer to the first projection. + /// Observer that runs the first projection per source element and feeds its sequence to the shared intermediate observer. private sealed class SourceWitness : IObserver { /// The downstream observer that ultimately receives values. @@ -37,10 +41,10 @@ private sealed class SourceWitness : IObserver /// First projection delegate. private readonly Func> _first; - /// Pre-allocated intermediate observer shared across every source emission. + /// Intermediate observer shared across every source emission. private readonly MidWitness _midObserver; - /// Initializes a new instance of the class and primes the reusable mid observer. + /// Initializes a new instance of the class. /// The downstream observer. /// First projection delegate. /// Second projection delegate. @@ -76,7 +80,7 @@ public void OnNext(TSource value) public void OnCompleted() => _downstream.OnCompleted(); } - /// Receives the intermediate value, applies second, and subscribes the resulting observable directly to downstream — no separate final-stage observer needed. + /// Observer that runs the second projection and subscribes its sequence straight to the downstream observer. /// The downstream observer. /// Second projection delegate. private sealed class MidWitness( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs index fa3186ce..bb74a950 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs @@ -7,7 +7,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Shuffles arrays in place using non-cryptographic randomness. +/// +/// Reorders each array the source emits in place with non-cryptographic randomness and forwards that same instance, so +/// any other holder of the array sees the new order. A null array is forwarded untouched. +/// /// The array element type. /// The source observable emitting arrays. public sealed class ShuffleObservable(IObservable source) : IObservable @@ -20,7 +23,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new ShuffleWitness(observer)); } - /// Randomizes buffered values for non-security use. + /// Observer that reorders each array in place before forwarding it. /// The downstream observer receiving shuffled arrays. [SuppressMessage( "Security", @@ -67,7 +70,6 @@ public void OnNext(T[] value) /// The array to shuffle in place. private static void ShuffleInPlace(T[] array) { - // This shuffle requires no cryptographic randomness. var random = _threadRandom; if (random is null) { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs index c71ffcfa..274c33dc 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SkipWhileNullObservable.cs @@ -21,10 +21,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new SkipWhileNullWitness(observer)); } - /// - /// Forwarding observer that swallows leading values until the first - /// non-null value, then becomes a transparent forwarder for the remainder of the sequence. - /// + /// Forwarding observer that drops leading nulls and forwards everything from the first non-null value onward. /// The downstream observer. private sealed class SkipWhileNullWitness(IObserver downstream) : IObserver { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs index afaa9203..b4d9a622 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs @@ -6,7 +6,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Subscribes to an observable sequence and executes an asynchronous handler for each element. +/// +/// Subscribes to a source and runs an asynchronous handler for each element, one at a time, queueing values that arrive +/// while a handler runs. A handler failure goes to the error callback and stops further processing; the completion +/// callback runs once the queue drains after the source completes, and disposal drops the subscription and the queue. +/// /// The type of elements in the source sequence. [System.Diagnostics.DebuggerDisplay("SubscribeAsyncObservable: Queued = {_queue.Count}, Processing = {_isProcessing}, Done = {_done}")] public sealed class SubscribeAsyncObservable : IDisposable @@ -94,7 +98,7 @@ internal Task OnNextAsync(T value) [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] private void OnNext(T value) => _ = OnNextAsync(value); - /// Called when an error occurs in the source. + /// Routes a source error to the error callback and stops processing the queue. /// The error that occurred. private void OnError(Exception error) { @@ -110,7 +114,7 @@ private void OnError(Exception error) } } - /// Called when the source completes. + /// Marks the source finished and runs the completion callback when no handler is in flight. private void OnCompleted() { lock (_gate) @@ -128,7 +132,7 @@ private void OnCompleted() } } - /// Processes the next value in the queue. + /// Runs the handler for queued values in turn, invoking the completion callback when the queue empties after the source finishes. /// A representing the asynchronous operation. private async Task ProcessNextAsync() { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs index 1f3ab5c7..d5924f09 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs @@ -6,7 +6,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Provides a fallback observable if the source sequence completes without emitting any elements. +/// +/// Forwards the source's values, and when the source completes without having emitted any, subscribes +/// and forwards that sequence instead. A source error propagates without the fallback being +/// tried. +/// /// The type of elements in the source sequence. /// The source observable. /// The fallback observable. @@ -27,7 +31,7 @@ public IDisposable Subscribe(IObserver observer) return sink; } - /// The sink for the . + /// Observer that tracks whether the source emitted and swaps in the fallback subscription when it did not. /// The downstream observer. /// The fallback observable. private sealed class SwitchIfEmptySink( @@ -37,7 +41,7 @@ private sealed class SwitchIfEmptySink( /// The gate for state access. private readonly Lock _gate = new(); - /// The current subscription. + /// The active subscription, replaced by the fallback's when the source turns out empty. private readonly MutableDisposable _subscription = new(); /// Whether the source has emitted a value. @@ -46,7 +50,7 @@ private sealed class SwitchIfEmptySink( /// Whether the sink has completed or been disposed. private bool _done; - /// Sets the subscription to the source observable. + /// Stores the source subscription so the fallback subscription can take its place. /// The subscription. public void SetSubscription(IDisposable sub) => _subscription.Disposable = sub; @@ -98,7 +102,6 @@ public void OnCompleted() } else { - // Source was empty, switch to fallback _subscription.Disposable = fallback.Subscribe(downstream); } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs index c26fdc65..eecb42ab 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs @@ -6,7 +6,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Wraps elements in a synchronization context that waits for a disposal signal before proceeding to the next element. +/// +/// Forwards each source value paired with a fresh disposable handle the consumer disposes to acknowledge it. Each value +/// carries its own handle, and a handle left undisposed leaves only its own acknowledgement wait outstanding. +/// /// The type of elements in the source sequence. /// The source observable. public sealed class SynchronizeAsyncObservable(IObservable source) : IObservable<(T Value, IDisposable Sync)> @@ -22,7 +25,7 @@ public IDisposable Subscribe(IObserver<(T Value, IDisposable Sync)> observer) return new DisposableBag(sub, sink); } - /// The sink for the . + /// Observer that pairs each value with a new acknowledgement handle and forwards the pair downstream. /// The downstream observer. internal sealed class SynchronizeAsyncSink(IObserver<(T Value, IDisposable Sync)> downstream) : IObserver, IDisposable { @@ -89,7 +92,7 @@ public void Dispose() } } - /// Delivers the value and waits for the consumer to dispose its acknowledgement signal. + /// Delivers the value with a fresh acknowledgement signal and returns the task that the signal's disposal completes. /// The value to process. /// A representing the asynchronous operation. private Task ProcessAsync(T value) @@ -99,10 +102,10 @@ private Task ProcessAsync(T value) return signal.WaitForDisposeAsync(); } - /// Releases one emission when the consumer disposes it, allocating completion state only for an asynchronous wait. + /// Acknowledgement handle for one emission whose disposal completes that emission's wait task. internal sealed class SyncSignal : IDisposable { - /// The lazily-created completion source; only allocated on the slow path. + /// The completion source, created only when the wait cannot finish synchronously. private TaskCompletionSource? _tcs; /// Latches to 1 on the first dispose so signalling is idempotent. @@ -120,7 +123,7 @@ public void Dispose() } /// Returns the disposal task; the producer must call this exactly once per emission. - /// A completed task if the consumer already disposed; otherwise the lazily-allocated TCS task. + /// A completed task when the handle has been disposed; otherwise the task that its disposal completes. internal Task WaitForDisposeAsync() { if (Volatile.Read(ref _disposed) == 1) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs index 01201e07..86cd9e05 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs @@ -4,7 +4,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// Takes elements from the source sequence until a predicate returns true for an element. The element that satisfies the predicate is included in the sequence. +/// +/// Forwards source elements until returns , emits that element too, +/// then completes. An exception from the predicate terminates the sequence before that element is emitted. +/// /// The type of elements in the source sequence. /// The source observable. /// The predicate to determine when to stop taking elements. @@ -22,7 +25,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new TakeUntilInclusiveWitness(observer, predicate)); } - /// The observer for the . + /// Observer that completes the sequence right after forwarding the first element the predicate accepts. /// The downstream observer. /// The predicate to determine when to stop taking elements. private sealed class TakeUntilInclusiveWitness( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs index 4f72e53b..fa5c7138 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Fused .Select(selector).Where(x => x is not null).Select(x => x!) operator -/// that applies a transform and emits only non-null results. Replaces the 3-operator -/// null-filtering chain with a single operator + observer allocation. -/// +/// Applies to each source element and emits only the non-null results; an exception from the selector terminates the sequence. /// The source element type. /// The projected element type. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs index 1c812559..66c49651 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs @@ -7,11 +7,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// -/// Fused Where(predicate).Take(1). Subscribes a single observer to the source, -/// emits the first value satisfying followed by -/// , then disposes the source subscription — -/// avoiding the two intermediate observer wrappers that the equivalent Rx chain would -/// allocate per subscription. +/// Emits the first source value that satisfies , completes, and disposes the source +/// subscription. An exception from the predicate terminates the sequence, and the source completing first ends the +/// sequence without a value. /// /// The element type of the source observable. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereFalseObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereFalseObservable.cs index 10997edd..57bbb0b9 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereFalseObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereFalseObservable.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Filtering operator that forwards only false values from a boolean source. -/// Replaces the source.Where(b => !b) pattern with a dedicated forwarding -/// observer, avoiding the per-subscription closure allocation that the predicate -/// lambda would otherwise capture. -/// +/// Forwards only the false values of a boolean source, passing errors and completion through unchanged. /// The boolean source observable. public sealed class WhereFalseObservable(IObservable source) : IObservable { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereIsNotNullObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereIsNotNullObservable.cs index d99fb441..b5df59c7 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereIsNotNullObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereIsNotNullObservable.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Filtering operator that forwards only the non-null values of the source -/// sequence. Replaces the source.Where(x => x is not null) pattern, -/// avoiding the per-subscription closure allocation that the predicate lambda -/// would otherwise capture. -/// +/// Forwards only the non-null values of the source sequence, passing errors and completion through unchanged. /// The element type of the source observable. /// The source observable whose null values are filtered out. public sealed class WhereIsNotNullObservable(IObservable source) : IObservable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs index 942d9850..36e48a61 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs @@ -7,10 +7,8 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// -/// Fused Where(predicate).Select(selector) operator. Replaces the two-operator -/// Rx chain with a single observable + observer pair, saving the intermediate -/// Select operator allocation (and its ) per -/// subscription on hot paths. +/// Emits applied to each source element that accepts. An +/// exception from either delegate terminates the sequence. /// /// The source element type. /// The projected element type after applying the selector. @@ -32,11 +30,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new WhereSelectWitness(observer, predicate, selector)); } - /// - /// Forwarding observer that applies the predicate and selector inline on each - /// . Any exception thrown by either delegate is routed to - /// on the downstream observer. - /// + /// Forwarding observer that applies the predicate then the selector, routing an exception from either to the downstream error channel. /// The downstream observer receiving projected values. /// Filter delegate. /// Projection delegate. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereTrueObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereTrueObservable.cs index 030968c1..17b9fec9 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereTrueObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereTrueObservable.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Filtering operator that forwards only true values from a boolean source. -/// Replaces the source.Where(b => b) pattern with a dedicated forwarding -/// observer, avoiding the per-subscription closure allocation that the predicate -/// lambda would otherwise capture. -/// +/// Forwards only the true values of a boolean source, passing errors and completion through unchanged. /// The boolean source observable. public sealed class WhereTrueObservable(IObservable source) : IObservable { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/SingleValueSignal.cs b/src/ReactiveUI.Primitives.Extensions.Core/SingleValueSignal.cs index 53d5cf07..828fb097 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/SingleValueSignal.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/SingleValueSignal.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Synchronously emits a single cached value to each subscriber and completes. Drop-in for -/// the Rx Observable.Return pattern when sharing the observable instance across calls. -/// +/// Emits one cached value to each subscriber and completes, both inside Subscribe; the Rx spelling is Observable.Return. /// The element type. /// The value emitted to every subscriber. public sealed class SingleValueSignal(T value) : IObservable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs b/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs index f12bd06c..4563c921 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs @@ -5,10 +5,9 @@ namespace ReactiveUI.Primitives.Extensions; /// -/// Represents either a staleness indicator or a value update from an observable stream. Value-type shape; -/// the stale-detection operator emits these directly so per-emission allocations are zero. Note that -/// default(Stale<T>) represents a value update with the default ; use -/// new Stale<T>() to construct a staleness signal. +/// Carries either a staleness signal or a value update from an observable sequence. default(Stale<T>) is a +/// value update holding the default ; construct a staleness signal with +/// new Stale<T>(), and read only when is . /// /// The type of the update value. [System.Diagnostics.DebuggerDisplay("Stale: IsStale = {IsStale}")] diff --git a/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs b/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs index e6c368f1..ec8bf461 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs @@ -13,19 +13,15 @@ namespace ReactiveUI.Primitives.Extensions; "Design", "SST2315:A type that owns a disposable should be disposable", Justification = - "The timer's lifetime is owned by the parent operator sink, which releases it under its own gate through " - + "HandleErrorLocked/HandleCompletedLocked/HandleDisposeLocked. This state object is deliberately not independently " - + "IDisposable: adding IDisposable would expose an ungated disposal path that races the sink's gate.")] + "The parent operator sink owns the timer's lifetime and releases it under its own gate, so an independent " + + "disposal path on this state object would race that gate.")] [System.Diagnostics.DebuggerDisplay("TimerSinkState: Done = {Done}, Timer = {Timer}")] public sealed class TimerSinkState(IObserver downstream) { /// Gets the timer slot used by the operator's OnNext logic to schedule deferred emissions. public SwapDisposable Timer { get; } = new(); - /// - /// Gets a value indicating whether the sink has reached a terminal state (OnError, OnCompleted, - /// or Dispose). Read inside the owning sink's gate by callers that need to short-circuit a deferred operation. - /// + /// Gets a value indicating whether the sink has terminated through error, completion or disposal; read it under the owning sink's gate. public bool Done { get; private set; } /// Forwards a terminal error to the downstream observer and tears the sink down. The caller must hold the sink's gate. diff --git a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs index ce3d54a4..182a0c42 100644 --- a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs @@ -9,6 +9,9 @@ namespace ReactiveUI.Primitives.Concurrency; /// MAUI dispatcher sequencer that coalesces scheduled work through an . +/// Work runs on the dispatcher's thread, one batch per posted drain; scheduling from that thread queues the +/// item for the next drain rather than running it inline. Delayed work goes to the dispatcher's own delayed dispatch, +/// which cannot be called off once armed, so a cancelled item is skipped when the delay elapses. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class MauiDispatcherSequencer : ISequencer diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs index f995af85..77aa8837 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs @@ -71,7 +71,7 @@ private static void AppendSanitized(PooledStringBuilder builder, string identity var start = 0; var end = identity.Length; - // Leading and trailing punctuation would sanitize to uninformative underscores; the hash still separates + // Leading and trailing punctuation would sanitize to uninformative underscores; the hash separates // identities that differ only there. while (start < end && !char.IsLetterOrDigit(identity[start])) { diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs index 7e25fc67..6f269ca7 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs @@ -237,7 +237,7 @@ private static void ReturnBuffer(char[] buffer) _pooledCount++; } - /// Grows the buffer when the requested length no longer fits. + /// Grows the buffer when the requested length exceeds its capacity. /// The total capacity required. private void EnsureCapacity(int required) { diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs index c613ae05..7ecd47c5 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs @@ -51,7 +51,7 @@ internal static void AppendFooter(PooledStringBuilder builder, string namespaceN _ = builder.AppendLine("}"); } - /// Emits a local event handler with the delegate's return type. + /// Emits one event's observable property, including the local handler that forwards the payload to the observer. /// The destination builder. /// The event to wrap. /// The observable implementation to write against. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs index a3affeae..8659b6c1 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs @@ -52,7 +52,7 @@ internal static bool IsActivationInvocation(SyntaxNode node, CancellationToken c : null; } - /// Extracts a host's original generic definition for a shared wrapper. + /// Builds the wrapper model for a host, collecting its events and any diagnostics they raise. /// The host to wrap, reduced to its original definition. /// The call site, for diagnostics. /// Whether the consumer's language can express an annotation. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs index 37bca49c..7429e67d 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs @@ -66,7 +66,7 @@ private static bool IsRequestAttributeName(NameSyntax name) _ => string.Empty, }; - /// Rejects generic static hosts whose type arguments cannot be inferred. + /// Builds the model for a static host, reporting a diagnostic instead when the host is generic. /// The host to expose, reduced to its original definition. /// The attribute application, for diagnostics. /// Whether the consumer's language can express an annotation. diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs index 2b5ca5d2..b9a0c997 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs @@ -251,7 +251,7 @@ private static string SelectPrimaryConstraint(ITypeParameterSymbol parameter, bo return parameter.HasNotNullConstraint ? "notnull" : string.Empty; } - /// Appends the constraint separator when something has already been written. + /// Appends the constraint separator unless the builder is empty. /// The destination builder. /// The builder, for chaining. private static PooledStringBuilder AppendSeparator(PooledStringBuilder builder) => diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs index 8a99328a..4fc2fd28 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs @@ -5,8 +5,8 @@ namespace ReactiveUI.Primitives.ObservableEvents.Models; /// Stores event emission data without compiler objects; parameterless payloads remain provider-independent. -/// The generated property name, already escaped. -/// The subscription target and event name, already escaped. +/// The generated property name, escaped for use as an identifier. +/// The subscription target and event name, escaped for use as an identifier. /// The observable payload type, or empty when the delegate takes no parameters. /// The value passed to OnNext, or empty when the delegate takes no parameters. /// The generated handler's parameter list. diff --git a/src/ReactiveUI.Primitives.Reactive/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives.Reactive/Concurrency/CurrentThreadSequencer.cs index 5bc4e9b1..f4ce7a4e 100644 --- a/src/ReactiveUI.Primitives.Reactive/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives.Reactive/Concurrency/CurrentThreadSequencer.cs @@ -12,6 +12,6 @@ internal static class CurrentThreadSequencer /// Gets the singleton current-thread scheduler. internal static IScheduler Instance => CurrentThreadScheduler.Instance; - /// Gets a value indicating whether the caller must call a Schedule method. + /// Gets a value indicating whether the caller must schedule work instead of running it inline. internal static bool IsScheduleRequired => CurrentThreadScheduler.IsScheduleRequired; } diff --git a/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs b/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs index 61a49691..0f6f4fb9 100644 --- a/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs +++ b/src/ReactiveUI.Primitives.Reactive/Concurrency/WasmScheduler.cs @@ -199,7 +199,7 @@ internal void RunReadyBatch() } } - /// Claims a drain or requests another pass if the observed state is still current. + /// Claims a drain, or requests another pass when the observed state has not changed. /// The drain state observed before attempting the transition. /// True when no further claim attempt is needed; false when the observed state changed. internal bool TryPostDrain(int observedState) diff --git a/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs b/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs index de0d9f10..5af649b3 100644 --- a/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs +++ b/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs @@ -21,8 +21,7 @@ public sealed class HandlerSequencer : CoalescingDispatchScheduler [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "SST1422:Move this field into the method that uses it", - Justification = - "Persistent lazy cache: the JNI runnable bridge is built once and reused across every Post call, so it cannot be a method local.")] + Justification = "The JNI runnable bridge is built once and reused across every posted batch.")] private Java.Lang.IRunnable? _drainRunnable; /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs index 1f8d04b6..24c38195 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs @@ -7,6 +7,9 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// Windows Forms scheduler that coalesces scheduled work through a UI control. +/// Work runs on the control's UI thread. Work scheduled before the control has a handle stays queued until +/// handle creation posts a drain, and delayed work waits on a background scheduler that marshals it back through the +/// control when due. /// [System.Diagnostics.DebuggerDisplay("ControlSequencer: Control = {Control}")] public sealed class ControlSequencer : CoalescingDispatchScheduler diff --git a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs index 1bd4ba56..4d883877 100644 --- a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs +++ b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs @@ -8,6 +8,10 @@ namespace ReactiveUI.Primitives.Concurrency; /// Windows Forms sequencer that coalesces scheduled work through a UI control. +/// Work runs on the control's UI thread, one batch per posted drain; scheduling from that thread queues the +/// item for the next drain rather than running it inline. Work scheduled before the control has a handle stays queued +/// until handle creation posts a drain, and delayed work waits on a shared timer that marshals it back through the +/// control when due. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ControlSequencer : ISequencer @@ -70,7 +74,7 @@ private bool Post(Action drain) } } - /// Forwards the cached drain callback to the engine. + /// Runs one queued batch on the coalescing engine. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RunDrain() => _state.RunDrain(); diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs index 58638557..23724cdc 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs @@ -8,6 +8,8 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// WinUI dispatcher queue scheduler that coalesces scheduled work through a . +/// Work runs on the dispatcher queue's thread and delayed work fires on a dispatcher queue timer, so disposing +/// the returned subscription stops that timer as well as suppressing work that has not started. /// [System.Diagnostics.DebuggerDisplay("DispatcherQueueSequencer: DispatcherQueue = {DispatcherQueue}, Priority = {Priority}")] public sealed class DispatcherQueueSequencer : CoalescingDispatchScheduler @@ -16,8 +18,7 @@ public sealed class DispatcherQueueSequencer : CoalescingDispatchScheduler [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "SST1422:Move this field into the method that uses it", - Justification = - "Persistent lazy cache: the dispatcher queue handler is built once and reused across every post, so it cannot be a method local.")] + Justification = "The handler delegate is cached across every post, so it cannot be a method local.")] private DispatcherQueueHandler? _handler; /// Initializes a new instance of the class. @@ -45,7 +46,7 @@ public DispatcherQueueSequencer(DispatcherQueue dispatcherQueue, DispatcherQueue public DispatcherQueuePriority Priority { get; } /// - /// The dispatcher queue is no longer accepting work. + /// The dispatcher queue rejected the work. protected override bool Post(Action drain) { _handler ??= drain.Invoke; diff --git a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs index eae31d1f..d4b3ffed 100644 --- a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs @@ -9,6 +9,9 @@ namespace ReactiveUI.Primitives.Concurrency; /// WinUI dispatcher queue sequencer that coalesces scheduled work through a . +/// Work runs on the dispatcher queue's thread, one batch per posted drain; scheduling from that thread queues +/// the item for the next drain rather than running it inline. Delayed work fires on a dispatcher queue timer, and an +/// item cancelled before its drain reaches it is skipped. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DispatcherQueueSequencer : ISequencer @@ -20,8 +23,7 @@ public sealed class DispatcherQueueSequencer : ISequencer [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "SST1422:Move this field into the method that uses it", - Justification = - "Persistent lazy cache: the dispatcher queue handler is built once and reused across every post, so it cannot be a method local.")] + Justification = "The handler delegate is cached across every post, so it cannot be a method local.")] private DispatcherQueueHandler? _handler; /// Initializes a new instance of the class. @@ -70,7 +72,7 @@ public DispatcherQueueSequencer(DispatcherQueue dispatcherQueue, DispatcherQueue /// Marshals the cached drain callback through the dispatcher queue. /// The drain callback. /// when the drain was enqueued. - /// The dispatcher queue is no longer accepting work. + /// The dispatcher queue rejected the work. private bool Post(Action drain) { _handler ??= drain.Invoke; @@ -99,7 +101,7 @@ private void ScheduleDelayed(IWorkItem item, long dueTimestamp) timer.Start(); } - /// Forwards the cached drain callback to the engine. + /// Runs one queued batch on the coalescing engine. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RunDrain() => _state.RunDrain(); } diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs index 3c262918..aa360fc7 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs @@ -8,12 +8,15 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// WPF dispatcher scheduler that coalesces scheduled work onto a dispatcher drain. +/// Work runs on the dispatcher's thread at and delayed work fires on a +/// , so disposing the returned subscription stops that timer as well as suppressing work +/// that has not started. /// [System.Diagnostics.DebuggerDisplay("DispatcherSequencer: Dispatcher = {Dispatcher}, Priority = {Priority}")] public sealed class DispatcherSequencer : CoalescingDispatchScheduler { /// Initializes a new instance of the class. - /// The dispatcher. + /// The dispatcher whose thread runs the scheduled work. /// is . public DispatcherSequencer(Dispatcher dispatcher) : this(dispatcher, DispatcherPriority.Normal) @@ -21,7 +24,7 @@ public DispatcherSequencer(Dispatcher dispatcher) } /// Initializes a new instance of the class. - /// The dispatcher. + /// The dispatcher whose thread runs the scheduled work. /// Dispatcher priority used for posted drains. /// is . public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) @@ -30,7 +33,7 @@ public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) Priority = priority; } - /// Gets the dispatcher. + /// Gets the dispatcher whose thread runs the scheduled work. public Dispatcher Dispatcher { get; } /// Gets the dispatcher priority used for posted drains. diff --git a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index 9047b599..b8680340 100644 --- a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -9,6 +9,9 @@ namespace ReactiveUI.Primitives.Concurrency; /// WPF dispatcher sequencer that coalesces scheduled work onto a dispatcher drain. +/// Work runs on the dispatcher's thread at , one batch per posted drain; scheduling from +/// that thread queues the item for the next drain rather than running it inline. Delayed work fires on a +/// , and an item cancelled before its drain reaches it is skipped. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DispatcherSequencer : ISequencer @@ -17,7 +20,7 @@ public sealed class DispatcherSequencer : ISequencer private DispatchSequencerState _state; /// Initializes a new instance of the class. - /// The dispatcher. + /// The dispatcher whose thread runs the scheduled work. /// is . public DispatcherSequencer(Dispatcher dispatcher) : this(dispatcher, DispatcherPriority.Normal) @@ -25,7 +28,7 @@ public DispatcherSequencer(Dispatcher dispatcher) } /// Initializes a new instance of the class. - /// The dispatcher. + /// The dispatcher whose thread runs the scheduled work. /// Dispatcher priority used for posted drains. /// is . public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) @@ -35,7 +38,7 @@ public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) _state = new(this, Post, RunDrain, ScheduleDelayed); } - /// Gets the dispatcher. + /// Gets the dispatcher whose thread runs the scheduled work. public Dispatcher Dispatcher { get; } /// Gets the dispatcher priority used for posted drains. @@ -84,7 +87,7 @@ private void ScheduleDelayed(IWorkItem item, long dueTimestamp) timer.Start(); } - /// Forwards the cached drain callback to the engine. + /// Runs one queued batch on the coalescing engine. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RunDrain() => _state.RunDrain(); } diff --git a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs index a609bb3a..97957e2b 100644 --- a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs @@ -95,7 +95,7 @@ public void Schedule(IWorkItem item) Schedule(item, Timestamp); } - /// Schedules a work item to be executed at the specified monotonic timestamp. + /// Runs the work item on the calling thread once due, blocking until then, or queues it on the trampoline when that thread is running scheduled work. /// Work item to execute. /// Absolute monotonic timestamp at which to execute the item. /// is . diff --git a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs index b02cc01e..88bf8903 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs @@ -45,7 +45,7 @@ public IDisposable Schedule(Action action) return EmptyDisposable.Instance; } - /// Schedules the specified work item. + /// Executes the work item on the calling thread unless it has been cancelled. /// Work item to execute. /// is . public void Schedule(IWorkItem item) @@ -60,7 +60,7 @@ public void Schedule(IWorkItem item) item.Execute(); } - /// Schedules the specified work item. + /// Blocks the calling thread until the due timestamp, then executes the work item unless it has been cancelled. /// Work item to execute. /// Absolute monotonic timestamp at which to execute the item. /// is . diff --git a/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs b/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs index fd187d0c..7bea91ee 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SequencerExtensions.cs @@ -192,7 +192,6 @@ public IDisposable Schedule(Action action) /// Action to execute. /// The disposable object used to cancel the scheduled action (best effort). /// or is null. - /// Named ScheduleAction because a Schedule overload taking state plus an action would be ambiguous with the others. public IDisposable ScheduleAction(TState state, Action action) { ArgumentExceptionHelper.ThrowIfNull(scheduler); diff --git a/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs b/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs index c67d9449..7865337f 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SynchronizedSequencerQueue.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Concurrency; -/// -/// Thread-safe wrapper around that pairs the queue with its lock, so the -/// virtual-time self-removal callback synchronizes through one reference instead of capturing the owning struct. -/// +/// Pairs a with the gate guarding it, so a callback synchronizes through one reference rather than capturing the owning struct. /// Absolute time representation type. internal sealed class SynchronizedSequencerQueue where TAbsolute : IComparable diff --git a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs index f18b34f6..f7de0562 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs @@ -301,7 +301,7 @@ internal sealed class ScheduledWorkItem( [SuppressMessage( "Usage", "CA2213:Disposable fields should be disposed", - Justification = "_owner is the sequencer that queued this work item, not a resource it owns; disposing it would shut the sequencer down when one item completes.")] + Justification = "The sequencer that queued this work item is not a resource the work item owns.")] private readonly ThreadPoolSequencer _owner = owner; /// Scheduled state. diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs index 2dfece9d..0addf589 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs @@ -112,9 +112,7 @@ public IDisposable Schedule(TState state, TimeSpan dueTime, Func( TState state, DateTimeOffset dueTime, diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs index f502c324..9ba9e375 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeMessages.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// Shares virtual-time message formats across clock types. internal static class VirtualTimeMessages { - /// Format of the message thrown when the clock is advanced while the scheduler is already running. + /// Format of the message thrown when the clock is advanced while the scheduler is running. private const string SchedulerAlreadyRunningFormat = "{0} cannot be called when the scheduler is already running. Try using Sleep instead."; @@ -23,7 +23,7 @@ internal static class VirtualTimeMessages CompositeFormat.Parse(SchedulerAlreadyRunningFormat); #endif - /// Builds the message thrown when a clock-advancing method runs against an already-running scheduler. + /// Builds the message thrown when a clock-advancing method runs against a running scheduler. /// The name of the clock-advancing method that was called. /// The formatted message. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs index e808ee42..fe630a61 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs @@ -60,10 +60,7 @@ public IDisposable ScheduleRelative(TRelative dueTime, Action action) ArgumentExceptionHelper.ThrowIfNull(action); - // As stated in Sequencer.Simple.cs, - // an anonymous delegate will allow delegate caching. - // Watch https://github.com/dotnet/roslyn/issues/5835 for compiler - // support for caching delegates from method groups. + // A static lambda rather than a method group, so the compiler caches the delegate. return scheduler.ScheduleRelative(action, dueTime, static (_, a) => Invoke(a)); } diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs index 41fd4ce1..b39a30f4 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs @@ -113,9 +113,7 @@ public IDisposable Schedule(TState state, TimeSpan dueTime, Func( TState state, DateTimeOffset dueTime, diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs index 54220103..d95c085e 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs @@ -13,8 +13,7 @@ namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "SST1803:Make record struct readonly", - Justification = - "This is mutable scheduler state; its members mutate the clock and running flag in place, so it cannot be readonly.")] + Justification = "Members mutate the clock and the running latch in place.")] internal record struct VirtualTimeState where TAbsolute : IComparable { @@ -76,7 +75,7 @@ public VirtualTimeState( /// Advances the scheduler's clock by the specified relative time, running all work scheduled for that timespan. /// Relative time to advance the scheduler's clock by. /// is negative. - /// The scheduler is already running. + /// The scheduler is running work. internal void AdvanceBy(TRelative time) { var dt = _add(_clock, time); @@ -103,7 +102,7 @@ internal void AdvanceBy(TRelative time) /// Advances the scheduler's clock to the specified time, running all work till that point. /// Absolute time to advance the scheduler's clock to. /// is in the past. - /// The scheduler is already running. + /// The scheduler is running work. internal void AdvanceTo(TAbsolute time) { var dueToClock = _comparer.Compare(time, _clock); diff --git a/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs index 23a1f68d..6c594f3b 100644 --- a/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs @@ -197,7 +197,7 @@ public void Execute() _owner.ScheduleReady(_item); } - /// Cancels the marshalled item, handing it back to the caller that still holds it. + /// Cancels the marshalled item, handing it back to the caller holding it. private void Release() { if (_item is not IDisposable cancellable) diff --git a/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs b/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs index d3611a72..91ebc63d 100644 --- a/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs +++ b/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs @@ -24,8 +24,7 @@ public sealed class HandlerSequencer : ISequencer [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "SST1422:Move this field into the method that uses it", - Justification = - "Persistent lazy cache: the JNI runnable bridge is built once and reused across every Post call, so it cannot be a method local.")] + Justification = "The JNI runnable bridge is built once and reused across every posted batch.")] private Java.Lang.IRunnable? _drainRunnable; /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives/RxVoid.cs b/src/ReactiveUI.Primitives/RxVoid.cs index 88fc9eb4..9762bbcb 100644 --- a/src/ReactiveUI.Primitives/RxVoid.cs +++ b/src/ReactiveUI.Primitives/RxVoid.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives; -/// A Reactive Void. +/// A unit type with exactly one value, carried by sequences that signal an occurrence rather than data. [Serializable] [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public readonly struct RxVoid : IEquatable diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AsyncExtensionsComparisonBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AsyncExtensionsComparisonBenchmarks.cs index d00ce00c..78f17674 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AsyncExtensionsComparisonBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/AsyncExtensionsComparisonBenchmarks.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// Benchmarks the new async primitives API against ReactiveUI.Extensions 4.0.0. +/// Benchmarks the async primitives API against the ReactiveUI.Extensions async equivalents. [MemoryDiagnoser] public class AsyncExtensionsComparisonBenchmarks { @@ -234,8 +234,7 @@ private static int Sum(PrimitivesCountingWitness[] observers) "Design", "SST2318:Members should not have identical bodies", Justification = - "Type-specialized overloads over different witness array types. The summation loop is identical, but the " - + "overloads take different element types and cannot forward to one another.")] + "The Sum overloads take different witness array element types and cannot forward to one another.")] private static int Sum(ExtensionsCountingWitness[] observers) { var total = 0; diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/CoreRuntimeBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/CoreRuntimeBenchmarks.cs index 42fab69a..bfa1dff9 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/CoreRuntimeBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/CoreRuntimeBenchmarks.cs @@ -44,7 +44,7 @@ public int PrimitivesPocketDispose() [SuppressMessage( "Allocations", "PSH1011:Use the overload with a state argument so this lambda does not capture", - Justification = "The baseline builds the same closure through a constructor, which has no state overload; de-closuring only this arm would invert the memory column it is compared on.")] + Justification = "De-closuring only this arm would skew the memory column it is compared on.")] public int SystemReactiveCompositeDispose() { var disposed = 0; @@ -63,7 +63,7 @@ public int SystemReactiveCompositeDispose() [SuppressMessage( "Allocations", "PSH1011:Use the overload with a state argument so this lambda does not capture", - Justification = "The baseline builds the same closure through a constructor, which has no state overload; de-closuring only this arm would invert the memory column it is compared on.")] + Justification = "De-closuring only this arm would skew the memory column it is compared on.")] public int R3CompositeDispose() { var disposed = 0; @@ -142,7 +142,7 @@ public int R3SafeWitness() return value; } - /// Allocating a completed spark should remain allocation efficient. + /// Allocating a completed spark with ReactiveUI.Primitives. /// An integer marker extracted from kind. [Benchmark] public int PrimitivesCompletedSpark() diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryAdapterExpansionBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryAdapterExpansionBenchmarks.cs index 153fc666..4ac6a0cb 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryAdapterExpansionBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryAdapterExpansionBenchmarks.cs @@ -293,8 +293,8 @@ public int R3NeverSubscribeDispose() return observer.NextCount + observer.CompletionCount + observer.ErrorCount; } - /// Gets the values. - /// The enumerable of values. + /// Yields the benchmark values asynchronously after an initial scheduler yield. + /// An async sequence of ascending values. private static async IAsyncEnumerable ValuesAsync() { await Task.Yield(); diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryGcProfileBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryGcProfileBenchmarks.cs index 91794798..f8514223 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryGcProfileBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/FactoryGcProfileBenchmarks.cs @@ -10,8 +10,7 @@ namespace ReactiveUI.Primitives.Benchmarks; /// /// GC-verbose allocation baselines for the factory operators, across Primitives, System.Reactive, -/// and R3. Delegates to the comparison benchmark methods so the scenarios stay in one place. -/// Opt in with --filter "*GcProfile*". +/// and R3. Delegates to the comparison benchmark methods. Opt in with --filter "*GcProfile*". /// [ShortRunJob] [MemoryDiagnoser] diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs index 52a64aa6..f254fe8a 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorPassThroughBenchmarks.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Benchmarks; -/// Benchmarks for the pass-through and materialization operators converted to dedicated signals (Tap, IgnoreValues, Spark/Unspark materialize round-trip, SubscribeOn, Reattempt). +/// Benchmarks the pass-through and materialization operators (Tap, IgnoreValues, Spark/Unspark materialize round-trip, SubscribeOn, Reattempt). [MemoryDiagnoser] public class OperatorPassThroughBenchmarks { @@ -217,9 +217,7 @@ private sealed class ImmediateSynchronizationContext : SynchronizationContext, I "Design", "SST2318:Members should not have identical bodies", Justification = - "Post and Send are distinct SynchronizationContext overrides that this immediate context deliberately " - + "implements the same way: run the callback inline. They are separate base-class overrides and cannot " - + "be collapsed.")] + "Post and Send are distinct SynchronizationContext overrides and cannot be collapsed into one.")] public override void Send(SendOrPostCallback d, object? state) => d(state); /// Releases the resources used by the synchronization context. diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterBenchmarks.cs index 60eebb1c..a9e5015d 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterBenchmarks.cs @@ -10,10 +10,9 @@ namespace ReactiveUI.Primitives.Benchmarks; /// -/// Subscribe-and-drain benchmarks for the stateful single-source operators that were converted -/// from per-subscription closures to dedicated single-source sinks (Skip, Unique, Fold, -/// Reduce, TakeWhile, SkipWhile, UniqueBy). The Allocated column reflects the closure-to-sink -/// reduction; System.Reactive is the comparison baseline. +/// Subscribe-and-drain benchmarks for the stateful single-source operators (Skip, Unique, Fold, +/// Reduce, TakeWhile, SkipWhile, UniqueBy), measured against the System.Reactive equivalents as +/// the comparison baseline. /// [MemoryDiagnoser] public class OperatorStatefulFilterBenchmarks diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterGcProfileBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterGcProfileBenchmarks.cs index 74f81472..ac719ae3 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterGcProfileBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorStatefulFilterGcProfileBenchmarks.cs @@ -9,9 +9,9 @@ namespace ReactiveUI.Primitives.Benchmarks; /// -/// GC-verbose allocation profile for the stateful single-source operators converted to dedicated -/// single-source sinks. Pairs with ; the -/// EventPipe trace captures per-subscription allocations on the subscribe-and-drain path. +/// GC-verbose allocation profile for the stateful single-source operators, pairing with +/// . The EventPipe trace captures per-subscription +/// allocations on the subscribe-and-drain path. /// [ShortRunJob] [MemoryDiagnoser] diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs index 99774a46..94b3380b 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/Program.cs @@ -104,9 +104,8 @@ public override void Write(char value) "Design", "SST2318:Members should not have identical bodies", Justification = - "Write(char) and Write(string?) are distinct TextWriter overrides that this tee-writer implements the " - + "same way: forward to both underlying writers. They are separate base-class overrides over different " - + "parameter types and cannot be collapsed.")] + "Write(char) and Write(string?) are distinct TextWriter overrides over different parameter types and " + + "cannot be collapsed into one.")] public override void Write(string? value) { primary.Write(value); diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.cs index 89ba6259..12d817b2 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.cs @@ -167,9 +167,7 @@ private enum ExtensionsLibrary "Design", "SST2318:Members should not have identical bodies", Justification = - "Distinct BenchmarkDotNet benchmarks that measure the same operation across libraries. The method body is " - + "identical by design; the library being compared is supplied by each method's own [ArgumentsSource]. They " - + "must stay separate so BenchmarkDotNet reports one row per library.")] + "Each benchmark must stay a separate method so BenchmarkDotNet reports one row per library.")] public int ReactiveUIExtensions(ExtensionScenario scenario) => scenario.Run(); /// Runs a System.Reactive comparison scenario. @@ -182,9 +180,7 @@ private enum ExtensionsLibrary "Design", "SST2318:Members should not have identical bodies", Justification = - "Distinct BenchmarkDotNet benchmarks that measure the same operation across libraries. The method body is " - + "identical by design; the library being compared is supplied by each method's own [ArgumentsSource]. They " - + "must stay separate so BenchmarkDotNet reports one row per library.")] + "Each benchmark must stay a separate method so BenchmarkDotNet reports one row per library.")] public int SystemReactive(ExtensionScenario scenario) => scenario.Run(); /// Runs an R3 comparison scenario. @@ -197,9 +193,7 @@ private enum ExtensionsLibrary "Design", "SST2318:Members should not have identical bodies", Justification = - "Distinct BenchmarkDotNet benchmarks that measure the same operation across libraries. The method body is " - + "identical by design; the library being compared is supplied by each method's own [ArgumentsSource]. They " - + "must stay separate so BenchmarkDotNet reports one row per library.")] + "Each benchmark must stay a separate method so BenchmarkDotNet reports one row per library.")] public int R3Library(ExtensionScenario scenario) => scenario.Run(); /// Creates the full paired library scenario list. diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectHotPathGcProfileBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectHotPathGcProfileBenchmarks.cs index 22278924..3fa758c5 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectHotPathGcProfileBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectHotPathGcProfileBenchmarks.cs @@ -9,10 +9,9 @@ namespace ReactiveUI.Primitives.Benchmarks; /// -/// GC-verbose profile for the subject hot paths touched by the lock-free copy-on-write rewrite of -/// , the System.Threading.Lock migration of the value-carrying subjects, and -/// the readonly-Observer change. Covers steady-state emission (per-OnNext should be allocation-free) -/// and subscribe/unsubscribe churn (exercises the copy-on-write add/remove path). +/// GC-verbose profile for the subject hot paths: steady-state emission through +/// and the value-carrying subjects, and single- and multi-subscriber subscribe/dispose churn over the +/// copy-on-write observer list. /// [ShortRunJob] [MemoryDiagnoser] diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectMulticastBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectMulticastBenchmarks.cs index 0e3df1ba..c81680b0 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectMulticastBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/SubjectMulticastBenchmarks.cs @@ -11,8 +11,8 @@ namespace ReactiveUI.Primitives.Benchmarks; /// -/// Benchmarks fan-out emission throughput: emitting a stream of values into a subject that already has several -/// observers attached, so the cost is dominated by the per-observer dispatch loop rather than subscription setup. +/// Benchmarks fan-out emission throughput: emitting a stream of values into a subject with several observers +/// attached, so the cost is dominated by the per-observer dispatch loop rather than subscription setup. /// [MemoryDiagnoser] public class SubjectMulticastBenchmarks diff --git a/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs b/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs index 98c0d3a7..21593a7d 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Disposables.Tests; /// Tests slot assignment, replacement disposal, and idempotent disposal. public class DisposableSlotHelperTests { - /// Verifies that an incoming value is disposed immediately if the slot is already disposed. + /// Verifies that an incoming value is disposed immediately when the slot is disposed. /// A representing the asynchronous test operation. [Test] public async Task WhenAssignWithoutDisposingPreviousIntoDisposedSlot_ThenIncomingDisposed() @@ -72,7 +72,7 @@ public async Task WhenSwapAndDisposePreviousOpen_ThenPreviousDisposed() await Assert.That(second.DisposeCount).IsEqualTo(0); } - /// Verifies the swap path disposes the incoming value if the slot is already disposed. + /// Verifies the swap path disposes the incoming value when the slot is disposed. /// A representing the asynchronous test operation. [Test] public async Task WhenSwapAndDisposePreviousIntoDisposedSlot_ThenIncomingDisposed() diff --git a/src/tests/ReactiveUI.Disposables.Tests/DisposableTests.cs b/src/tests/ReactiveUI.Disposables.Tests/DisposableTests.cs index 3cded947..ba7e2955 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/DisposableTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/DisposableTests.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Disposables.Tests; /// Tests for the disposables family of types. public class DisposableTests { - /// Called when [dispose once]. + /// Verifies an action disposable runs its action exactly once across repeated disposal. /// A representing the asynchronous test operation. [Test] public async Task OnlyDisposeOnce() @@ -22,7 +22,7 @@ public async Task OnlyDisposeOnce() await Assert.That(disposed).IsEqualTo(1); } - /// Empties the disposable. + /// Verifies the empty disposable singleton tolerates repeated disposal without effect. [Test] public void EmptyDisposableInstanceDoesNothing() { @@ -48,7 +48,7 @@ public async Task CancellationDisposableDefaultConstructorCancelsOwnedToken() await Assert.That(disposable.IsDisposed).IsTrue(); } - /// Singles the disposable dispose. + /// Verifies a single disposable reports itself disposed once disposed. /// A representing the asynchronous test operation. [Test] public async Task SingleDisposableDispose() @@ -58,7 +58,7 @@ public async Task SingleDisposableDispose() await Assert.That(disposable.IsDisposed).IsTrue(); } - /// Singles the disposable dispose with action. + /// Verifies a single disposable runs its dispose action exactly once across repeated disposal. /// A representing the asynchronous test operation. [Test] public async Task SingleDisposableDisposeWithAction() @@ -76,7 +76,7 @@ public async Task SingleDisposableDisposeWithAction() await Assert.That(disposed).IsEqualTo(1); } - /// Verifies action runs once and late assigned disposable is still disposed. + /// Verifies the dispose action runs once and a disposable created after disposal is disposed too. /// A representing the asynchronous test operation. [Test] public async Task SingleDisposableDisposeThenCreateRunsActionOnce() @@ -93,7 +93,7 @@ public async Task SingleDisposableDisposeThenCreateRunsActionOnce() await Assert.That(created).IsEqualTo(1); } - /// Multiples the disposable dispose. + /// Verifies an empty group reports itself disposed once disposed. /// A representing the asynchronous test operation. [Test] public async Task MultipleDisposableDispose() @@ -103,7 +103,7 @@ public async Task MultipleDisposableDispose() await Assert.That(disposable.IsDisposed).IsTrue(); } - /// Multiples the disposable with items dispose. + /// Verifies disposing a group disposes every disposable it holds. /// A representing the asynchronous test operation. [Test] public async Task MultipleDisposableWithItemsDispose() @@ -111,7 +111,6 @@ public async Task MultipleDisposableWithItemsDispose() MultipleDisposable disposable = [EmptyDisposable.Instance]; var disposed = 0; - // A child disposable whose action runs when the group is disposed. SingleDisposable singleDisposable = new(EmptyDisposable.Instance, () => disposed++); disposable.Add(singleDisposable); SingleDisposable singleDisposable2 = new(EmptyDisposable.Instance); @@ -233,7 +232,6 @@ public async Task MultipleDisposableHandlesOverflow() MultipleDisposable disposable = [.. items]; await Assert.That(disposable.Count).IsEqualTo(items.Length); - // Enumerate and copy while the group spills into the overflow store. var seen = 0; foreach (var _ in disposable) { @@ -252,7 +250,6 @@ public async Task MultipleDisposableHandlesOverflow() await Assert.That(disposable.Remove(missing)).IsFalse(); await Assert.That(disposable.Count).IsEqualTo(items.Length - 1); - // Clear disposes the remaining items, including the overflow store. disposable.Clear(); await Assert.That(disposedCount).IsEqualTo(items.Length); } @@ -304,7 +301,6 @@ public async Task MultipleDisposableAfterDisposeIsEmptyAndDisposesNewItems() disposable.Add(new ActionDisposable(() => lateDisposed++)); await Assert.That(lateDisposed).IsEqualTo(1); - // Clear and a redundant Dispose are no-ops on an already-disposed group. disposable.Clear(); disposable.Dispose(); await Assert.That(disposable.IsDisposed).IsTrue(); diff --git a/src/tests/ReactiveUI.Disposables.Tests/MutableDisposableTests.cs b/src/tests/ReactiveUI.Disposables.Tests/MutableDisposableTests.cs index a6b554bc..791e1bb7 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/MutableDisposableTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/MutableDisposableTests.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Disposables.Tests; -/// 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. +/// Tests for , whose inner can be replaced without disposing the value it held. public class MutableDisposableTests { /// Verifies replacement leaves the previous inner alone. diff --git a/src/tests/ReactiveUI.Disposables.Tests/SwapDisposableTests.cs b/src/tests/ReactiveUI.Disposables.Tests/SwapDisposableTests.cs index 9651a29e..56644a5b 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/SwapDisposableTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/SwapDisposableTests.cs @@ -6,9 +6,7 @@ namespace ReactiveUI.Disposables.Tests; -/// Tests for — verifies replacement disposes the previous -/// inner, assigning after disposal immediately disposes the incoming value, and Dispose -/// is idempotent. +/// Tests for , which disposes the value it held each time its inner is replaced. public class SwapDisposableTests { /// Verifies that replacement disposes the previous inner. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AotSafeAssertionExtensions.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AotSafeAssertionExtensions.cs index 4b1b986b..99c16375 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AotSafeAssertionExtensions.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AotSafeAssertionExtensions.cs @@ -18,12 +18,7 @@ internal static class AotSafeAssertionExtensions extension(IAssertionSource source) where TCollection : IEnumerable { - /// - /// Asserts the collection is equivalent to - /// using the element type's default - /// (mirroring IsEquivalentTo) without the reflection-based structural - /// comparison that triggers trim/AOT warnings. - /// + /// Asserts element-wise equality with using , which keeps the assertion trim-safe. /// The expected element sequence. /// The chained collection-equivalency assertion. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs index 04c8123c..1876ce79 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncSerialGateTests.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests gate acquisition, reentry, contention and idempotent disposal. public class AsyncSerialGateTests { - /// Verifies that the uncontended fast path acquires the gate via pure CAS. + /// Verifies that an uncontended gate can be acquired again after release. /// A representing the asynchronous test operation. [Test] public async Task WhenUncontendedLock_ThenAcquiresAndReleases() @@ -19,14 +19,13 @@ public async Task WhenUncontendedLock_ThenAcquiresAndReleases() await Assert.That(gate).IsNotNull(); } - // After release the gate must be re-acquirable. using (await gate.EnterAsync()) { await Assert.That(gate).IsNotNull(); } } - /// Verifies that same-thread reentry bumps the recursion depth and does not block. + /// Verifies that nested acquisitions on the owning thread are granted without blocking. /// A representing the asynchronous test operation. [Test] public async Task WhenSameThreadReentry_ThenAllowedWithoutBlocking() @@ -47,7 +46,6 @@ public async Task WhenSameThreadReentry_ThenAllowedWithoutBlocking() lease1.Dispose(); } - // Gate must release cleanly after nested acquisitions. using (await gate.EnterAsync()) { await Assert.That(gate).IsNotNull(); @@ -69,7 +67,7 @@ public async Task WhenContendedWaiter_ThenResumesAfterRelease() await Assert.That(gate.WaitersCount).IsEqualTo(0); } - /// Verifies that double-dispose is idempotent. + /// Verifies that a second dispose of the gate has no effect. /// A representing the asynchronous test operation. [Test] public async Task WhenDisposeCalledTwice_ThenIdempotent() diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs index 011c2212..29cbcefa 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// A that is not and runs each queued task -/// on the thread pool. Used to exercise the code paths that branch on a caller-supplied scheduler. -/// +/// A scheduler distinct from that runs each queued task on the thread pool. internal sealed class CustomTaskScheduler : TaskScheduler { /// Singleton instance. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs index f9fd33e3..7dd57b85 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ExpireOperatorTests.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// Tests for the Expire operator — the inter-element deadline that completes the sequence with a -/// . Covers the deadline firing, and the tick of a deadline that was rearmed -/// by a source value arriving after the sequence had already expired. -/// +/// Tests the Expire operator's inter-element deadline, which completes the sequence with a . public class ExpireOperatorTests { /// The inter-element deadline used by the tests. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs index a44c2a24..7d51a55d 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalOperatorTests.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// Tests for the Interval operator's shutdown path: disposing the subscription from inside a tick -/// cancels the tick loop between one notification and the next, so the loop exits on its own cancellation -/// check rather than by tearing a pending delay down with an exception. -/// +/// Tests the Interval operator's tick loop and its shutdown path. public class IntervalOperatorTests { /// The tick the handler disposes on. @@ -17,8 +13,7 @@ public class IntervalOperatorTests /// The interval between ticks. private static readonly TimeSpan TickPeriod = TimeSpan.FromMilliseconds(20); - /// Verifies that disposing the interval subscription from inside a tick handler ends the tick loop: - /// the ticks seen so far start at one and are consecutive, and nothing arrives after the dispose. + /// Verifies that disposing from inside a tick handler delivers no tick after the one that disposed. /// A representing the asynchronous test operation. [Test] public async Task WhenDisposedFromWithinATick_ThenTheTickLoopStops() diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs index 4118c091..9f584cea 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionCapture.cs @@ -21,7 +21,7 @@ internal sealed class UnhandledExceptionCapture : IDisposable /// Signals the next captured exception. private TaskCompletionSource _arrival = new(TaskCreationOptions.RunContinuationsAsynchronously); - /// Tracks whether the capture has already restored the previous handler. + /// Set to one once the prior handler has been restored. private int _disposed; /// Initializes a new instance of the class. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs index 024a48bd..d33ca978 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/UnhandledExceptionTestExecutor.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// Test executor that saves and restores the global state -/// around each test, ensuring test isolation for tests that register custom handlers. -/// +/// Saves and restores the global state around each test. public sealed class UnhandledExceptionTestExecutor : ITestExecutor { /// diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/DiagnosticInfoTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/DiagnosticInfoTests.cs index b2e0dacc..20f1711c 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/DiagnosticInfoTests.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/DiagnosticInfoTests.cs @@ -50,7 +50,7 @@ public async Task DiagnosticInfoRebuildsATwoArgumentDiagnostic() await Assert.That(diagnostic.GetMessage()).Contains(HostDisplayName); } - /// Verifies a diagnostic with nowhere to point still reports rather than throwing. + /// Verifies a diagnostic with nowhere to point reports rather than throwing. /// A task representing the asynchronous operation. [Test] public async Task DiagnosticInfoReportsWithoutALocation() diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs index 89d95732..8c3a55c1 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.cs @@ -892,7 +892,7 @@ private static List CreateReferences( return references; } - /// Adds a metadata reference unless the same assembly path is already present. + /// Adds a metadata reference unless the same assembly path is present. /// The destination references. /// The assembly path to add. private static void AddReference(List references, string path) diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/GeneratedNamesTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/GeneratedNamesTests.cs index c76142ea..8b21c371 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/GeneratedNamesTests.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/GeneratedNamesTests.cs @@ -34,7 +34,7 @@ public async Task GeneratedNamesTrimsLeadingAndTrailingPunctuation() await Assert.That(trimmed).IsNotEqualTo(bare); } - /// Verifies an identity with nothing nameable still produces a usable identifier. + /// Verifies an identity with nothing nameable produces a usable identifier. /// A task representing the asynchronous operation. [Test] public async Task GeneratedNamesHandlesAnIdentityWithNothingNameable() diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/PooledStringBuilderTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/PooledStringBuilderTests.cs index fef5a047..ec30bd60 100644 --- a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/PooledStringBuilderTests.cs +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/PooledStringBuilderTests.cs @@ -142,8 +142,7 @@ public async Task PooledStringBuilderDropsBuffersBeyondTheFreeListCapacity() builder.Return(); } - // The pool is now full; a fresh builder still has to work, whether it was handed a pooled buffer or a - // newly allocated one. + // With the free list saturated, this builder gets either a pooled buffer or a fresh allocation. await Assert.That(new PooledStringBuilder().Append("after").ToStringAndReturn()).IsEqualTo("after"); } @@ -159,7 +158,7 @@ public async Task PooledStringBuilderIndentsEveryNonEmptyLine() await Assert.That(builder.ToStringAndReturn()).IsEqualTo(" first\n\n third\n"); } - /// Verifies a block whose last line has no terminator still gets one. + /// Verifies a block whose last line has no terminator gets one appended. /// A task representing the asynchronous operation. [Test] public async Task PooledStringBuilderTerminatesAnUnterminatedFinalLine() diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/CurrentThreadSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/CurrentThreadSequencerTests.cs index c6dd36ac..3e23fe7e 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/CurrentThreadSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/CurrentThreadSequencerTests.cs @@ -8,10 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Tests; -/// -/// Verifies forwards to System.Reactive's current-thread scheduler, -/// including the trampoline flag callers use to decide whether they must schedule. -/// +/// Verifies forwards to System.Reactive's current-thread scheduler, trampoline flag included. public class CurrentThreadSequencerTests { /// Verifies the exposed instance is System.Reactive's current-thread scheduler singleton. @@ -21,11 +18,7 @@ public async Task WhenInstanceRead_ThenItIsTheCurrentThreadScheduler() => await Assert.That(CurrentThreadSequencer.Instance) .IsSameReferenceAs(System.Reactive.Concurrency.CurrentThreadScheduler.Instance); - /// - /// Verifies the schedule-required flag mirrors System.Reactive. Outside a trampoline both are - /// ; the assertion compares the two rather than a literal so the test states the - /// forwarding contract rather than a snapshot of the runtime's state. - /// + /// Verifies the schedule-required flag mirrors System.Reactive's. /// A representing the asynchronous test operation. [Test] public async Task WhenScheduleRequiredRead_ThenItMirrorsTheCurrentThreadScheduler() => diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs index e79f6de2..517dfb5c 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/DispatchWorkItemBaseTests.cs @@ -9,17 +9,13 @@ namespace ReactiveUI.Primitives.Reactive.Tests; -/// -/// Tests for the run/cancel handshake in : when a cancel claims the -/// item after its action has started but before the run finishes, the disposable the action produced must still -/// be torn down. -/// +/// Tests the run/cancel handshake in , which tears down work a cancelled action returned. public sealed class DispatchWorkItemBaseTests { /// The scheduled state value the probe work item carries. private const int ProbeState = 0; - /// Verifies a cancel that races the running action still disposes the work the action returned. + /// Verifies a cancel that races the running action disposes the work the action returned. /// A task representing the asynchronous operation. [Test] public async Task RunDisposesTheStartedWorkWhenCancellationClaimsTheItemDuringItsAction() diff --git a/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs index 9a108b57..7f942eb1 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/AsyncSignalTests.cs @@ -22,19 +22,19 @@ public class AsyncSignalTests /// The values expected after the first emission. private static readonly int[] FirstEmittedValues = [FirstEmittedValue]; - /// Subscribes the argument checking. + /// Subscribing with a null observer is rejected. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void Subscribe_ArgumentChecking() => Assert.Throws(static () => new AsyncSignal().Subscribe(null!)); - /// Called when [error argument checking]. + /// Faulting with a null error is rejected. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void OnError_ArgumentChecking() => Assert.Throws(static () => new AsyncSignal().OnError(null!)); - /// Awaits the blocking. + /// The signal is its own awaiter and reports completion once a value arrives. /// A task representing the asynchronous operation. [Test] public async Task Await_Blocking() @@ -45,7 +45,7 @@ public async Task Await_Blocking() await Assert.That(s.IsCompleted).IsTrue(); } - /// Awaits the throw. + /// The signal is its own awaiter and reports completion once a fault arrives. /// A task representing the asynchronous operation. [Test] public async Task Await_Throw() @@ -56,7 +56,7 @@ public async Task Await_Throw() await Assert.That(s.IsCompleted).IsTrue(); } - /// Gets the result empty. + /// Reading the result of a signal that completed without a value throws. [Test] public void GetResult_Empty() { @@ -65,7 +65,7 @@ public void GetResult_Empty() _ = Assert.Throws(() => s.GetResult()); } - /// Gets the result blocking. + /// A pending wait delivers a value and leaves the signal completed. /// A task representing the asynchronous operation. [Test] public async Task GetResult_Blocking() @@ -75,7 +75,7 @@ public async Task GetResult_Blocking() await Assert.That(s.IsCompleted).IsTrue(); } - /// Gets the result blocking throw. + /// A pending wait delivers a fault and leaves the signal completed. /// A task representing the asynchronous operation. [Test] public async Task GetResult_Blocking_Throw() @@ -85,7 +85,7 @@ public async Task GetResult_Blocking_Throw() await Assert.That(s.IsCompleted).IsTrue(); } - /// Gets the result context. + /// A continuation registered under a synchronization context is posted through that context. /// A task representing the asynchronous operation. [Test] public async Task GetResult_Context() @@ -110,7 +110,7 @@ public async Task GetResult_Context() await Assert.That(context.Ran).IsTrue(); } - /// Determines whether this instance has observers. + /// Observer presence tracks subscriptions as they are added and disposed. /// A task representing the asynchronous operation. [Test] public async Task HasObservers() @@ -131,7 +131,7 @@ public async Task HasObservers() await Assert.That(s.HasObservers).IsFalse(); } - /// Determines whether [has observers dispose1]. + /// Disposing the signal drops its observers, and disposing a live subscription afterwards is safe. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose1() @@ -150,7 +150,7 @@ public async Task HasObservers_Dispose1() await Assert.That(s.IsDisposed).IsTrue(); } - /// Determines whether [has observers dispose2]. + /// Disposing the last subscription clears the observers and leaves the signal undisposed. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose2() @@ -169,7 +169,7 @@ public async Task HasObservers_Dispose2() await Assert.That(s.IsDisposed).IsTrue(); } - /// Determines whether [has observers dispose3]. + /// Disposing a signal that was never subscribed to reports no observers. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose3() @@ -182,7 +182,7 @@ public async Task HasObservers_Dispose3() await Assert.That(s.IsDisposed).IsTrue(); } - /// Determines whether [has observers on completed]. + /// Completing the signal releases its observers. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_OnCompleted() @@ -198,7 +198,7 @@ public async Task HasObservers_OnCompleted() d.Dispose(); } - /// Determines whether [has observers on error]. + /// Faulting the signal releases its observers. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_OnError() @@ -216,10 +216,7 @@ public async Task HasObservers_OnError() d.Dispose(); } - /// - /// An async signal that completes without ever producing a value has no terminal value to replay. A late - /// subscriber must simply be completed — not handed a fabricated default, and not left hanging. - /// + /// A signal that completes without producing a value only completes its late subscribers. /// A task representing the asynchronous operation. [Test] public async Task CompletingWithoutAValueOnlyCompletesLateSubscribers() @@ -237,7 +234,7 @@ public async Task CompletingWithoutAValueOnlyCompletesLateSubscribers() await Assert.That(signal.HasObservers).IsFalse(); } - /// Covers async-signal subscriber churn, late subscriptions, disposal, and terminal no-op branches. + /// Subscriber churn, late subscriptions, repeated terminals, and disposal all hold on an async signal. /// A task representing the asynchronous operation. [Test] public async Task AsyncSignalSubscriberChurnLateTerminalsAndDisposalCoverBranches() diff --git a/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs index dcf4edd8..07fc5708 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs @@ -22,19 +22,19 @@ public class BehaviorSignalTests /// Value that should be ignored after completion. private const int IgnoredAfterCompletionValue = 1234; - /// Subscribes the argument checking. + /// Verifies a behavior signal rejects a null observer. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void Subscribe_ArgumentChecking() => Assert.Throws(static () => new BehaviorSignal(1).Subscribe(null!)); - /// Called when [error argument checking]. + /// Verifies a behavior signal rejects a null error. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void OnError_ArgumentChecking() => Assert.Throws(static () => new BehaviorSignal(1).OnError(null!)); - /// Determines whether this instance has observers. + /// Verifies a behavior signal tracks observers as subscriptions are added and removed. /// A task representing the asynchronous operation. [Test] public async Task HasObservers() @@ -55,7 +55,7 @@ public async Task HasObservers() await Assert.That(s.HasObservers).IsFalse(); } - /// Determines whether [has observers dispose1]. + /// Verifies a behavior signal drops its observers when the signal is disposed first. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose1() @@ -74,7 +74,7 @@ public async Task HasObservers_Dispose1() await Assert.That(s.IsDisposed).IsTrue(); } - /// Determines whether [has observers dispose2]. + /// Verifies a behavior signal drops its observers when the subscription is disposed first. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose2() @@ -93,7 +93,7 @@ public async Task HasObservers_Dispose2() await Assert.That(s.IsDisposed).IsTrue(); } - /// Determines whether [has observers dispose3]. + /// Verifies a behavior signal with no subscribers reports itself as disposed. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose3() @@ -106,7 +106,7 @@ public async Task HasObservers_Dispose3() await Assert.That(s.IsDisposed).IsTrue(); } - /// Determines whether [has observers on completed]. + /// Verifies completion drops a behavior signal's observers. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_OnCompleted() @@ -121,7 +121,7 @@ public async Task HasObservers_OnCompleted() await Assert.That(s.HasObservers).IsFalse(); } - /// Determines whether [has observers on error]. + /// Verifies an error drops a behavior signal's observers. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_OnError() @@ -138,7 +138,7 @@ public async Task HasObservers_OnError() await Assert.That(s.HasObservers).IsFalse(); } - /// Values the initial. + /// Verifies a new behavior signal exposes the initial value it was constructed with. /// A task representing the asynchronous operation. [Test] public async Task Value_Initial() @@ -149,7 +149,7 @@ public async Task Value_Initial() await Assert.That(x).IsEqualTo(InitialValue); } - /// Values the first. + /// Verifies a behavior signal's value follows the first emitted value. /// A task representing the asynchronous operation. [Test] public async Task Value_First() @@ -164,7 +164,7 @@ public async Task Value_First() await Assert.That(x).IsEqualTo(FirstUpdatedValue); } - /// Values the second. + /// Verifies a behavior signal's value follows each subsequent emitted value. /// A task representing the asynchronous operation. [Test] public async Task Value_Second() @@ -183,7 +183,7 @@ public async Task Value_Second() await Assert.That(x).IsEqualTo(SecondUpdatedValue); } - /// Values the frozen after on completed. + /// Verifies a completed behavior signal keeps its last value and ignores later values. /// A task representing the asynchronous operation. [Test] public async Task Value_FrozenAfterOnCompleted() @@ -210,7 +210,7 @@ public async Task Value_FrozenAfterOnCompleted() await Assert.That(x).IsEqualTo(SecondUpdatedValue); } - /// Values the throws after on error. + /// Verifies reading the value of a faulted behavior signal rethrows its error. /// A task representing the asynchronous operation. [Test] public async Task Value_ThrowsAfterOnError() @@ -222,7 +222,7 @@ public async Task Value_ThrowsAfterOnError() _ = Assert.Throws(() => s.TryGetValue(out _)); } - /// Values the throws on dispose. + /// Verifies reading the value of a disposed behavior signal throws. /// A task representing the asynchronous operation. [Test] public async Task Value_ThrowsOnDispose() diff --git a/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs index d8915ad2..d8f19a47 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// Tests for the fused operator (merge + distinct-until-changed in a single sink). +/// Tests for the fused operator. public class BlendUniqueTests { /// The value one. @@ -39,10 +39,7 @@ public class BlendUniqueTests /// Expected case-insensitive distinct result. private static readonly string[] _distinctCaseInsensitive = ["a", "B"]; - /// - /// Verifies that the merged stream forwards only values that differ from the previously forwarded one - /// and completes once every source has completed. - /// + /// Verifies the merged stream drops values equal to the last forwarded one and completes once every source does. /// A task representing the asynchronous operation. [Test] public async Task MergesSourcesAndSuppressesConsecutiveDuplicates() @@ -50,7 +47,6 @@ public async Task MergesSourcesAndSuppressesConsecutiveDuplicates() List values = []; var completed = 0; - // source0 emits 1,1,2 (-> 1,2) then source1 emits 2,3,3 (2 == last, dropped -> 3). _ = LinqExtensions .BlendUnique( Signal.FromEnumerable(_firstDuplicatedThenSecond), @@ -79,7 +75,6 @@ public async Task UsesSuppliedComparer() { List values = []; - // Case-insensitive: "a","A" collapse; "B" forwarded. _ = LinqExtensions.BlendUnique([Signal.FromEnumerable(_caseVariants)], StringComparer.OrdinalIgnoreCase) .Subscribe(values.Add); await Assert.That(values.SequenceEqual(_distinctCaseInsensitive)).IsTrue(); @@ -110,7 +105,7 @@ public async Task DisposeUnsubscribesFromSources() var subscription = LinqExtensions.BlendUnique(source).Subscribe(values.Add); source.OnNext(One); subscription.Dispose(); - source.OnNext(Two); // no longer subscribed -> ignored + source.OnNext(Two); await Assert.That(values.SequenceEqual(_single)).IsTrue(); } @@ -126,11 +121,11 @@ public async Task SuppressesNotificationsAfterTerminalError() Exception? error = null; var completed = 0; _ = LinqExtensions.BlendUnique(first, second, third).Subscribe(values.Add, ex => error = ex, () => completed++); - first.OnNext(One); // forwarded - second.OnError(new InvalidOperationException("boom")); // terminal - first.OnNext(Two); // value suppressed (done) - first.OnCompleted(); // completion suppressed (done) - third.OnError(new InvalidOperationException("again")); // error suppressed (done) + first.OnNext(One); + second.OnError(new InvalidOperationException("boom")); + first.OnNext(Two); + first.OnCompleted(); + third.OnError(new InvalidOperationException("again")); await Assert.That(values.SequenceEqual(_single)).IsTrue(); await Assert.That(error).IsNotNull(); await Assert.That(completed).IsEqualTo(0); diff --git a/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs index cce2b2b8..9ba2fc4e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// Coverage for the public equality and copy-on-write surface. +/// Tests for the public equality and copy-on-write surface. public class BroadcasterTests { /// The literal one. @@ -27,21 +27,16 @@ public async Task BroadcasterEqualityOperatorsCompareTheObserverSet() Broadcaster left = default; Broadcaster right = default; - // Both empty -> same (null) observer set. await Assert.That(left == right).IsTrue(); await Assert.That(left != right).IsFalse(); left.Add(new DelegateWitness(static _ => { })); - // Left now references an observer set; right is still empty. + // Left references an observer set; right is empty. await Assert.That(left != right).IsTrue(); await Assert.That(left == right).IsFalse(); } - /// - /// The hash follows the observer set, which has three shapes. An empty broadcaster hashes to zero, and a - /// broadcaster holding exactly one observer hashes to that observer's identity — so two broadcasters over - /// the same single observer agree, which is what equality promises. - /// + /// An empty broadcaster hashes to zero and a single-observer broadcaster hashes to that observer's identity. /// A task representing the asynchronous operation. [Test] public async Task BroadcasterHashesToZeroWhenEmptyAndToTheObserverIdentityWhenSingle() @@ -61,7 +56,7 @@ public async Task BroadcasterHashesToZeroWhenEmptyAndToTheObserverIdentityWhenSi await Assert.That(single.Equals(alsoSingle)).IsTrue(); } - /// Covers broadcaster copy-on-write, signal late-terminal, and buffer disposal/error branches. + /// Verifies broadcaster copy-on-write, late terminal notifications on a signal, and buffer error handling. /// A task representing the asynchronous operation. [Test] public async Task BroadcasterCopyOnWriteSignalAndBufferCoverTerminalEdges() @@ -109,7 +104,7 @@ public async Task BroadcasterCopyOnWriteSignalAndBufferCoverTerminalEdges() source.OnNext(One); source.OnNext(Two); - // The window (size 3) is incomplete; completion flushes the partial trailing window. + // The size-3 window is incomplete, so completion flushes the partial window. source.OnCompleted(); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/CalmCoordinatorTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CalmCoordinatorTests.cs index d0cb15ef..1085355f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CalmCoordinatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CalmCoordinatorTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Tests for the quiet-period coordinator behind Calm and its Throttle alias, whose completion -/// has to deliver the value still waiting inside the quiet window instead of discarding it. -/// +/// Tests for the quiet-period coordinator behind Calm and its Throttle alias. public sealed class CalmCoordinatorTests { /// The integer constant one. @@ -22,10 +19,10 @@ public sealed class CalmCoordinatorTests /// The marker for a completion that has not been observed yet. private const int NoCompletionObserved = -1; - /// The quiet period used by these tests; its timer only fires when the test runs it. + /// The quiet period used by these tests. private static readonly TimeSpan QuietPeriod = TimeSpan.FromMilliseconds(50); - /// Verifies each new value extends the quiet period without emitting the previous value. + /// Verifies each new value extends the quiet period without emitting the earlier value. /// A task representing the asynchronous operation. [Test] public async Task ThrottleWaitsForTheQuietPeriodAfterTheLatestValue() @@ -118,7 +115,7 @@ public async Task CompletionFlushesOnlyTheNewestBufferedValue() await Assert.That(witness.Completed).IsEqualTo(1); } - /// Verifies completion does not repeat a value the quiet-period timer has already delivered. + /// Verifies completion does not repeat a value the quiet-period timer delivered. /// A task representing the asynchronous operation. [Test] public async Task CompletionDoesNotRepeatAValueTheTimerAlreadyDelivered() diff --git a/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs index e236e3d2..63efd95b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs @@ -14,7 +14,7 @@ public class ChooseSwitchSelectTests /// The value ten. private const int Ten = 10; - /// The value eleven (stale inner value that must be ignored). + /// The value eleven, emitted by a superseded inner source. private const int Eleven = 11; /// The value twenty. @@ -72,10 +72,7 @@ public async Task ChooseForwardsChooserError() await Assert.That(error is InvalidOperationException).IsTrue(); } - /// - /// Verifies that SwitchSelect skips null source values, mirrors the latest inner observable, and ignores - /// values from a superseded inner observable. - /// + /// Verifies SwitchSelect skips null values, mirrors the latest inner source, and ignores a superseded one. /// A task representing the asynchronous operation. [Test] public async Task SwitchSelectFiltersNullSwitchesAndIgnoresStaleInner() @@ -85,12 +82,12 @@ public async Task SwitchSelectFiltersNullSwitchesAndIgnoresStaleInner() Signal inner2 = new(); List values = []; _ = outer.SwitchSelect(key => key == KeyA ? inner1 : inner2).Subscribe(values.Add); - outer.OnNext(null); // skipped (null) - outer.OnNext(KeyA); // subscribe inner1 - inner1.OnNext(Ten); // forwarded - outer.OnNext(KeyB); // switch to inner2; inner1 superseded - inner1.OnNext(Eleven); // stale -> ignored - inner2.OnNext(Twenty); // forwarded + outer.OnNext(null); + outer.OnNext(KeyA); + inner1.OnNext(Ten); + outer.OnNext(KeyB); + inner1.OnNext(Eleven); + inner2.OnNext(Twenty); await Assert.That(values.SequenceEqual(_tenThenTwenty)).IsTrue(); } @@ -106,10 +103,10 @@ public async Task SwitchSelectCompletesAfterOuterAndInner() static _ => { }, static ex => throw ex, () => completed++); - outer.OnNext(KeyA); // active inner - outer.OnCompleted(); // outer done, inner still active -> not complete + outer.OnNext(KeyA); + outer.OnCompleted(); await Assert.That(completed).IsEqualTo(0); - inner.OnCompleted(); // now complete + inner.OnCompleted(); await Assert.That(completed).IsEqualTo(Once); } @@ -189,8 +186,8 @@ public async Task SwitchSelectDisposeUnsubscribes() outer.OnNext(KeyA); inner.OnNext(Ten); subscription.Dispose(); - inner.OnNext(Eleven); // disposed -> ignored - outer.OnNext(KeyB); // disposed -> ignored + inner.OnNext(Eleven); + outer.OnNext(KeyB); await Assert.That(values.SequenceEqual(_tenOnly)).IsTrue(); } @@ -207,9 +204,9 @@ public async Task SwitchSelectCompletesWhenInnerThenOuterComplete() static ex => throw ex, () => completed++); outer.OnNext(KeyA); - inner.OnCompleted(); // inner done; outer still open -> not complete + inner.OnCompleted(); await Assert.That(completed).IsEqualTo(0); - outer.OnCompleted(); // outer done, no active inner -> complete + outer.OnCompleted(); await Assert.That(completed).IsEqualTo(Once); } @@ -228,11 +225,7 @@ public async Task SwitchSelectCompletesWhenOuterCompletesWithNoValue() await Assert.That(completed).IsEqualTo(Once); } - /// - /// Verifies the SwitchSelect race guards drop notifications from a superseded inner observable and - /// from the outer/active-inner sources after disposal — the defensive early-returns that a - /// well-behaved (unsubscribing) source would otherwise hide. - /// + /// Verifies the SwitchSelect guards drop superseded-inner notifications and every notification after disposal. /// A task representing the asynchronous operation. [Test] public async Task SwitchSelectGuardsIgnoreStaleAndPostDisposeNotifications() @@ -245,17 +238,16 @@ public async Task SwitchSelectGuardsIgnoreStaleAndPostDisposeNotifications() var completed = 0; var subscription = outer.SwitchSelect(key => key == KeyA ? inner1 : inner2) .Subscribe(values.Add, ex => error = ex, () => completed++); - outer.Next(KeyA); // inner1 active - outer.Next(KeyB); // inner2 active; inner1 now superseded + outer.Next(KeyA); + outer.Next(KeyB); - // Superseded inner1 (its id != the latest): every notification hits the stale guard. inner1.Next(Eleven); inner1.Error(new InvalidOperationException(Boom)); inner1.Complete(); subscription.Dispose(); - subscription.Dispose(); // idempotent: the second dispose hits the disposed guard + subscription.Dispose(); // Disposing twice must be a no-op. - // After disposal every outer and active-inner notification hits the disposed guard. + // ManualObservable keeps pushing after disposal, so the guards are what drop these. outer.Next(KeyA); outer.Error(new InvalidOperationException(Boom)); outer.Complete(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs index 4f78e7ef..ab8b2529 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CommandExecutionTests.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies , the awaitable a command hands back. It exists so a command -/// that finishes synchronously does not have to allocate a task, which means it has three shapes to honour — -/// a task, a bare result, and a bare exception — behind one awaiter. -/// +/// Verifies , the awaitable a command hands back. public class CommandExecutionTests { /// The value a successful command produces. @@ -31,10 +27,7 @@ public async Task AwaitingASynchronousCommandReturnsItsResult() await Assert.That(await execution).IsEqualTo(CommandResult); } - /// - /// A synchronous command that fails is carried as a bare exception, and awaiting it must rethrow that exact - /// exception rather than an wrapper. - /// + /// Awaiting a failed synchronous command rethrows the original exception, not an . /// A task that completes when the synchronous-fault assertions finish. [Test] public async Task AwaitingAFailedSynchronousCommandRethrowsTheOriginalException() @@ -49,10 +42,7 @@ public async Task AwaitingAFailedSynchronousCommandRethrowsTheOriginalException( await Assert.That(thrown!).IsSameReferenceAs(fault); } - /// - /// ConfigureAwait hands back a fresh awaitable that carries the same outcome. It must not lose the - /// result on the way through, whichever of the three shapes the execution is carrying. - /// + /// ConfigureAwait preserves the outcome of a task, a bare result, and a bare exception alike. /// A task that completes when the configure-await assertions finish. [Test] public async Task ConfigureAwaitPreservesTheOutcomeOfEveryExecutionShape() @@ -75,11 +65,7 @@ public async Task ConfigureAwaitPreservesTheOutcomeOfEveryExecutionShape() await Assert.That(thrown!).IsSameReferenceAs(fault); } - /// - /// The awaiter implements the plain - /// continuation path as well as the critical one the C# compiler prefers. A caller that schedules through it - /// must still be resumed, and must still see the result. - /// + /// A continuation scheduled through is resumed and sees the result. /// A task that completes when the continuation assertions finish. [Test] public async Task TheAwaiterResumesAContinuationScheduledThroughOnCompleted() diff --git a/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs index b2bb0e42..051b2eca 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CommandSignalTests.cs @@ -68,7 +68,7 @@ public async Task CommandSignalPublishesResultsFailuresAndRunningState() await Assert.That(rejected!.Message).IsEqualTo("Command cannot run."); } - /// Verifies command aliases, sync execution failures, and disposal branches. + /// A synchronous command that throws publishes the fault, and a disposed command rejects execution. /// A task that completes when command assertions finish. [Test] public async Task CommandSignalCoversSyncFaultAndDisposalBranches() @@ -130,11 +130,7 @@ public async Task InstallingAStaleRunningSnapshotReconcilesCompletion() await Assert.That(installed.Value).IsFalse(); } - /// - /// Verifies the running-state stream is allocated lazily, cached on the second access, and - /// reports when first observed on an idle command (the install CAS wins - /// and the post-install reconcile publishes the authoritative flag). - /// + /// The running-state stream is allocated lazily, cached, and reports on an idle command. /// A task that completes when the lazy-allocation assertions finish. [Test] public async Task IsRunningAllocatesLazilyAndCachesTheStream() @@ -148,11 +144,7 @@ public async Task IsRunningAllocatesLazilyAndCachesTheStream() await Assert.That(first.Value).IsFalse(); } - /// - /// Verifies a normal true-then-false transition flows through an already-installed stream: the - /// stream is observed before execution, so SetRunning takes the "stream present" path on - /// both edges and the running flag returns to at the end. - /// + /// A stream observed before execution sees the true-then-false transition and ends at . /// A task that completes when the transition assertions finish. [Test] public async Task IsRunningTransitionsTrueThenFalseThroughInstalledStream() @@ -167,11 +159,7 @@ public async Task IsRunningTransitionsTrueThenFalseThroughInstalledStream() await Assert.That(running.SequenceEqual(ExpectedRunningValues)).IsTrue(); } - /// - /// Verifies that when an execution completes without the running-state stream ever having been - /// observed, SetRunning exercises the "stream still null" reconciliation branch and a - /// later first observation still reports . - /// + /// A first observation made after the execution completes reports . /// A task that completes when the deferred-observation assertions finish. [Test] public async Task IsRunningReportsFalseWhenObservedOnlyAfterExecution() @@ -183,12 +171,7 @@ public async Task IsRunningReportsFalseWhenObservedOnlyAfterExecution() await Assert.That(command.IsRunning.Value).IsFalse(); } - /// - /// Drives the lazy install deterministically: the stream is first observed while an async - /// execution is in flight (running flag true), then the execution completes and lowers it. This - /// exercises the install-side re-sync seeding a value followed by the - /// installed-stream completion edge. - /// + /// A stream first observed mid-flight reports , then once the execution completes. /// A task that completes when the mid-flight assertions finish. [Test] public async Task IsRunningObservedMidFlightSettlesFalseAfterCompletion() @@ -219,15 +202,13 @@ public async Task ResultsFanOutToEverySubscriberAndStopAtUnsubscribe() _ = command.ExecuteAsync(); - // Remove from the middle of a three-observer array: the survivors must both keep receiving. + // Removing the middle of a three-observer array leaves both survivors receiving. secondSubscription.Dispose(); _ = command.ExecuteAsync(); - // Remove from a two-observer array, collapsing it back to a single observer. thirdSubscription.Dispose(); _ = command.ExecuteAsync(); - // Removing the last observer, then disposing the same handle again, must both be safe. firstSubscription.Dispose(); firstSubscription.Dispose(); _ = command.ExecuteAsync(); @@ -238,10 +219,7 @@ public async Task ResultsFanOutToEverySubscriberAndStopAtUnsubscribe() await Assert.That(first.TrueForAll(static value => value == CommandResult)).IsTrue(); } - /// - /// Disposing the command drops its observer set, so a subscription handle disposed afterwards has nothing - /// to detach from. That must be a quiet no-op rather than a failure. - /// + /// Disposing a result subscription after the command is disposed is a quiet no-op. /// A task that completes when the post-disposal assertions finish. [Test] public async Task ResultSubscriptionDisposedAfterTheCommandIsSafe() @@ -257,18 +235,14 @@ public async Task ResultSubscriptionDisposedAfterTheCommandIsSafe() _ = Assert.Throws(() => command.Results.Subscribe(results.Add)); } - /// - /// An async command that faults publishes the fault to the fault stream before the awaited task rethrows it, - /// and still lowers the running flag on the way out. - /// + /// An async command that faults publishes the fault before the await rethrows it, and lowers the running flag. /// A task that completes when the async-fault assertions finish. [Test] public async Task AsyncExecutionPublishesTheFaultAndStillLowersTheRunningFlag() { InvalidOperationException fault = new("async failed"); - // The delegate type is spelled out because a body that only throws gives the compiler no return - // expression to infer Task from. + // The delegate type is spelled out: a throw-only body gives the compiler nothing to infer Task from. Func> execute = async token => { await Task.Yield(); @@ -299,10 +273,7 @@ public async Task AsyncExecutionPublishesTheFaultAndStillLowersTheRunningFlag() await Assert.That(command.IsRunning.Value).IsFalse(); } - /// - /// The fault stream is allocated on first use and cached thereafter, and disposing the command tears down - /// the gate subscription along with the streams it created. - /// + /// The fault stream is cached after first use, and disposing the command releases the gate subscription. /// A task that completes when the lazy-fault-stream assertions finish. [Test] public async Task FaultsAllocateLazilyAndDisposalReleasesTheGateSubscription() @@ -319,7 +290,7 @@ public async Task FaultsAllocateLazilyAndDisposalReleasesTheGateSubscription() command.Dispose(); - // The command released the gate, so the gate signal no longer feeds anything. + // The command released the gate, so the gate signal feeds nothing. await Assert.That(canRun.HasObservers).IsFalse(); await Assert.That(running.IsDisposed).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs index 94a77a1a..cb79a139 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs @@ -10,9 +10,8 @@ namespace ReactiveUI.Primitives.Tests; /// -/// Compiles a snippet of consumer code against the shipped Primitives assemblies so a test can assert what a -/// downstream project's compiler resolves. Overload resolution against a referenced assembly is not observable -/// from inside this assembly, where the same names bind through source rather than metadata. +/// Compiles consumer code against the shipped Primitives assemblies to assert what a downstream compiler resolves. +/// Overload resolution against a referenced assembly binds through metadata, not through this assembly's source. /// public static class ConsumerCompilation { diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs index a3be5b8d..7bbc0e0b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/FirstInlineSequencer.cs @@ -12,10 +12,10 @@ namespace ReactiveUI.Primitives.Tests; [System.Diagnostics.DebuggerDisplay("FirstInlineSequencer: Now = {Now}, Started = {_started}, Pending = {_pending.Count}")] public sealed class FirstInlineSequencer(TimeSpan advanceBeforeFirst) : ISequencer { - /// The work items queued after the first, still waiting to run. + /// The work items queued after the first, waiting to run. private readonly List _pending = []; - /// Whether the inline first item has already run. + /// Whether the inline first item has run. private bool _started; /// Gets the sequencer's notion of current time. diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs index 390da735..c346c6b3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/ManualSequencer.cs @@ -34,10 +34,7 @@ internal sealed class ManualSequencer : ISequencer [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "The relative and absolute Schedule overloads of this test-double sequencer intentionally behave the same " - + "way; both are required by the ISequencer contract and, as distinct interface overloads, cannot forward " - + "to one another.")] + Justification = "Both ISequencer Schedule overloads of this test double queue the item without honoring a due time.")] public void Schedule(IWorkItem item, long dueTimestamp) => _pending.Add(item); /// Moves the sequencer's clock forward without running any work. diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs index 42469d70..505dabf5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// Creates a minimal tick-based used to exercise scheduling edge branches. +/// Creates a minimal tick-based . internal static class MinimalVirtualClock { /// Adds a tick offset to an absolute tick value. diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/ThrowingComparer.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/ThrowingComparer.cs index f4d33540..ebd6f3fc 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/ThrowingComparer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/ThrowingComparer.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Tests; /// An equality comparer that throws when comparing values. internal sealed class ThrowingComparer : IEqualityComparer { - /// Defers to a faulting comparison so the equality comparison throws when invoked. + /// Throws instead of comparing the two values. /// The first value to compare. /// The second value to compare. /// This method never returns; the faulting comparison always throws. diff --git a/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs index c18c71fd..5f0ca985 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ConnectableSignalTests.cs @@ -123,7 +123,6 @@ public async Task MulticastRoutesSourceValuesThroughTheSuppliedHub() List observed = []; using var subscription = multicast.Subscribe(observed.Add); - // No connection yet, so the hub must not see the source at all. source.OnNext(UnobservedSharedValue); await Assert.That(observed.Count).IsEqualTo(0); @@ -299,7 +298,6 @@ public async Task AutoShareConnectsOnFirstSubscriberAndDisconnectsOnLast() var secondSubscription = shared.Subscribe(second.Add); source.OnNext(FirstSharedValue); - // The single upstream connection feeds every observer. await Assert.That(sourceSubscriptions).IsEqualTo(1); firstSubscription.Dispose(); @@ -307,7 +305,6 @@ public async Task AutoShareConnectsOnFirstSubscriberAndDisconnectsOnLast() secondSubscription.Dispose(); - // The connection is disposed only once the final subscriber leaves. await Assert.That(sourceDisposals).IsEqualTo(1); await Assert.That(first.SequenceEqual(ExpectedFirstSharedValues)).IsTrue(); await Assert.That(second.SequenceEqual(ExpectedFirstSharedValues)).IsTrue(); @@ -339,7 +336,6 @@ public async Task AutoShareReconnectsAfterAllSubscribersLeave() await Assert.That(sourceSubscriptions).IsEqualTo(1); await Assert.That(sourceDisposals).IsEqualTo(1); - // A fresh subscriber after the count returned to zero forces a new connection. using var second = shared.Subscribe(static _ => { }); await Assert.That(sourceSubscriptions).IsEqualTo(ExpectedConnections); await Assert.That(sourceDisposals).IsEqualTo(1); @@ -368,12 +364,10 @@ public async Task AutoShareConnectFailureUnwindsRefcount() var shared = cold.Share().AutoShare(); - // Connect runs outside the gate; a synchronous failure surfaces to the caller. var thrown = Assert.Throws(() => shared.Subscribe(static _ => { })); await Assert.That(thrown).IsSameReferenceAs(expected); await Assert.That(subscribeAttempts).IsEqualTo(1); - // The failed attempt unwound the count, so the next subscriber reconnects rather than stalling. shouldThrow = false; List values = []; using var recovered = shared.Subscribe(values.Add); diff --git a/src/tests/ReactiveUI.Primitives.Tests/DelegateWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DelegateWitnessTests.cs index 2b3dca91..c703857c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/DelegateWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/DelegateWitnessTests.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// Coverage for the public surface. +/// Tests for . public class DelegateWitnessTests { /// The literal one. diff --git a/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerStateTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerStateTests.cs index 79e72b09..e425e2b0 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerStateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/DispatchSequencerStateTests.cs @@ -228,14 +228,13 @@ public async Task RejectedPostReleasesTheDrainLatch() sequencer.PostSucceeds = true; sequencer.Schedule(new RecordingWorkItem(values, NestedDrainValue)); - // A latch left set by the rejected post would swallow this second post, stranding both work items. await Assert.That(sequencer.PostCount).IsEqualTo(ExpectedRetriedPostCount); sequencer.RunNextDrain(); await Assert.That(values.SequenceEqual(ExpectedDrainPair)).IsTrue(); } - /// Verifies a dispatcher post that throws surfaces the failure and still releases the drain latch. + /// Verifies a dispatcher post that throws surfaces the failure and releases the drain latch. /// A task representing the asynchronous operation. [Test] public async Task FailedPostReleasesTheDrainLatchAndRethrows() @@ -255,7 +254,7 @@ await Assert.That(() => sequencer.Schedule(new RecordingWorkItem(values, OuterDr await Assert.That(values.SequenceEqual(ExpectedDrainPair)).IsTrue(); } - /// Verifies a drain re-entered by one of its own work items still runs every queued item exactly once. + /// Verifies a drain re-entered by one of its own work items runs every queued item exactly once. /// A task representing the asynchronous operation. [Test] public async Task ReentrantDrainRunsEachQueuedItemExactlyOnce() @@ -294,8 +293,7 @@ private TestDispatchSequencer() /// public long Timestamp => DispatchSequencerState.Timestamp; - /// Creates a sequencer whose dispatch state is wired only after construction has finished, - /// so the engine never sees a half-built owner. + /// Creates a sequencer wired to a dispatch state. /// The optional fallback delay sequencer. /// The wired sequencer. public static TestDispatchSequencer Create(ISequencer? sharedTimer = null) diff --git a/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs index b879f1b3..6282ea44 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/DisposableTests.cs @@ -99,7 +99,7 @@ public async Task SingleReplaceableDisposableRunsActionOnlyOnce() await Assert.That(actionCount).IsEqualTo(1); } - /// Covers disposable slot constructor, disposal, removal, and disposed-assignment branches. + /// Verifies slot assignment after disposal, replacement, and removal from a disposable group. /// A task representing the asynchronous operation. [Test] public async Task DisposableSlotsCoverAssignmentReplacementAndRemovalBranches() @@ -125,7 +125,7 @@ public async Task DisposableSlotsCoverAssignmentReplacementAndRemovalBranches() DisposeEveryConstructedSlotShape(); } - /// Verifies low-level disposables, collections, and schedulers cover deterministic edges. + /// Verifies the disposable, copy-on-write list, priority queue, and scheduling contracts. /// A task representing the asynchronous operation. [Test] public async Task LowLevelDisposablesCollectionsAndSchedulersCoverDeterministicEdges() @@ -188,7 +188,7 @@ public async Task LowLevelDisposablesCollectionsAndSchedulersCoverDeterministicE await Assert.That(scheduled.Count).IsEqualTo(Two); } - /// Exercises value types, disposables, and handle delegates. + /// Verifies core value-type equality, disposable lifecycles, and the handle delegates. /// A task representing the asynchronous operation. [Test] public async Task CoreValueTypesDisposablesAndHandlesCoverEqualityAndLifecycleBranches() @@ -268,7 +268,7 @@ private static async Task AssertDisposableLifecycleBranches() await Assert.That(multiple.IsDisposed).IsTrue(); } - /// Asserts the protected Dispose(false) path still disposes the underlying disposable exactly once. + /// Asserts the protected Dispose(false) path disposes the underlying disposable exactly once. /// A task representing the asynchronous operation. private static async Task AssertProtectedDisposePathRunsTheUnderlyingDisposableOnce() { @@ -290,7 +290,7 @@ private static async Task AssertProtectedDisposePathRunsTheUnderlyingDisposableO await Assert.That(multipleFalse).IsEqualTo(1); } - /// Asserts removal detaches an item from the group and disposal reaches only the items still in it. + /// Asserts removal detaches an item from the group and disposal reaches only the remaining items. /// A task representing the asynchronous operation. private static async Task AssertMultipleDisposableRemovesItemsAndDisposesTheRest() { diff --git a/src/tests/ReactiveUI.Primitives.Tests/EventPatternTests.cs b/src/tests/ReactiveUI.Primitives.Tests/EventPatternTests.cs index 40f34363..669b51d5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/EventPatternTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/EventPatternTests.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies equality and formatting contracts. public class EventPatternTests { - /// Covers event-pattern equality, hashing, formatting, and argument validation. + /// Verifies event-pattern equality, hashing, formatting, and argument validation. /// A task representing the asynchronous operation. [Test] public async Task EventPatternEqualityAndFormattingCoverContracts() @@ -36,7 +36,7 @@ public async Task EventPatternEqualityAndFormattingCoverContracts() }); } - /// Covers typed-sender event-pattern equality, hashing, and formatting. + /// Verifies typed-sender event-pattern equality, hashing, and formatting. /// A task representing the asynchronous operation. [Test] public async Task TypedSenderEventPatternEqualityAndFormattingCoverContracts() @@ -199,7 +199,7 @@ private sealed class TestEventArgs(int value) : EventArgs public int Value { get; } = value; } - /// Source used to exercise generic event conversion. + /// An event source that raises a generic handler event carrying an integer value. private sealed class GenericEventSource { /// Raised by the test source. @@ -211,7 +211,7 @@ private sealed class GenericEventSource public void Raise(int value) => Changed?.Invoke(this, new(value)); } - /// Source used to exercise a WPF-style non-generic event handler shape. + /// An event source that raises an assembly-load event through a non-generic handler. private sealed class AssemblyLoadEventSource { /// The handlers attached to the test source. @@ -234,13 +234,13 @@ private sealed class AssemblyLoadEventSource public void Raise(System.Reflection.Assembly assembly) => _assemblyLoaded?.Invoke(this, new(assembly)); } - /// Source used to exercise event conversion. + /// An event source that raises a property-changed event for a named property. private sealed class PropertyChangedEventSource { /// The handlers attached to the test source. private PropertyChangedEventHandler? _propertyChanged; - /// Gets a placeholder property name used by the event test. + /// Gets a placeholder property whose name the raised event carries. public static int Value => 0; /// Adds a handler to the test source. @@ -257,7 +257,7 @@ private sealed class PropertyChangedEventSource public void Raise(string propertyName) => _propertyChanged?.Invoke(this, new(propertyName)); } - /// Source used to exercise event conversion. + /// An event source that raises a collection-changed event with an add action. private sealed class CollectionChangedEventSource { /// The handlers attached to the test source. @@ -277,7 +277,7 @@ public void RaiseAdd() => _collectionChanged?.Invoke(this, new(NotifyCollectionChangedAction.Add, "value")); } - /// Source used to exercise event conversion. + /// An event source that raises a list-changed event with an item-added action. private sealed class ListChangedEventSource { /// The handlers attached to the test source. @@ -307,7 +307,7 @@ private sealed class FakeClickEventArgs : EventArgs public string Command { get; } } - /// Fake event source used by event-pattern bridge scenarios. + /// An event source that raises a click event carrying a command name. private sealed class FakeButton { /// Raised when the fake button is clicked. diff --git a/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs index bcf39bb5..9d25ca5a 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/EverySignalTests.cs @@ -26,7 +26,7 @@ public sealed class EverySignalTests /// The inactivity window Expire allows before it times the sequence out. private static readonly TimeSpan ExpiryPeriod = TimeSpan.FromMilliseconds(50); - /// An inline first tick retains the successor it schedules before the initial scheduling call returns. + /// A tick that fires inline during scheduling keeps the successor tick it arms. /// A task representing the asynchronous operation. [Test] public async Task EveryRetainsTheTickArmedByAnInlineFirstTick() @@ -56,7 +56,7 @@ public async Task EveryOnTheCurrentThreadSequencerStopsWhenTakeReachesItsCount() await Assert.That(completions).IsEqualTo(1); } - /// Verifies the current-thread ticks stay on the subscribing thread rather than moving to a pool thread. + /// Verifies the current-thread ticks are delivered on the subscribing thread. /// A task representing the asynchronous operation. [Test] public async Task EveryOnTheCurrentThreadSequencerTicksOnTheSubscribingThread() diff --git a/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs index d0edd33e..29253f48 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ExpireCoordinatorTests.cs @@ -23,7 +23,7 @@ public sealed class ExpireCoordinatorTests /// A gap shorter than that must not expire the timeout. private const int ActiveGapTicks = 9; - /// A gap shorter than after which a value still arrives in time. + /// A gap shorter than after which a value arrives in time. private const int ShortGapTicks = 5; /// The values forwarded by the active-source re-arming test. @@ -51,7 +51,7 @@ public async Task TimeoutResetsOnEachValueSoActiveSourceNeverExpires() await Assert.That(values.SequenceEqual(ExpectedActiveValues)).IsTrue(); } - /// Verifies silence longer than the timeout still expires after re-arming. + /// Verifies silence longer than the timeout expires the sequence after re-arming. /// A task representing the asynchronous operation. [Test] public async Task TimeoutExpiresWhenSilenceExceedsDueTimeAfterAValue() @@ -198,15 +198,11 @@ private sealed class UndispatchedSequencer(DateTimeOffset start) : ISequencer [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "The relative and absolute Schedule overloads of this test-double sequencer intentionally behave the " - + "same way; both are required by the ISequencer contract and, as distinct interface overloads, cannot " - + "forward to one another.")] + Justification = "Distinct interface overloads cannot forward to one another.")] public void Schedule(IWorkItem item, long dueTimestamp) => Pending++; } - /// Observer that makes the armed timeout due from inside , so the timeout is - /// dispatched while the value is still in flight. + /// Observer that makes the armed timeout due from inside . /// The clock that dispatches due work inline. /// The amount to advance the clock by so the armed timeout becomes due. private sealed class ReentrantTimeoutObserver(VirtualClock clock, TimeSpan dueTime) : IObserver diff --git a/src/tests/ReactiveUI.Primitives.Tests/ForkJoinWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ForkJoinWitnessTests.cs index 267bff31..c3cb7d7d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ForkJoinWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ForkJoinWitnessTests.cs @@ -53,7 +53,6 @@ public async Task ForkJoinWitnessForwardsFirstErrorAndGatesAfterwards() await Assert.That(observer.Errors).HasSingleItem(); await Assert.That(observer.Errors[0]).IsSameReferenceAs(error); - // Everything from either side is gated after the terminal error. right.Observer!.OnNext(Two); right.Observer.OnError(new InvalidOperationException("right")); right.Observer.OnCompleted(); @@ -100,7 +99,6 @@ public async Task ForkJoinWitnessIgnoresErrorAfterCompletion() await Assert.That(observer.Completed).IsEqualTo(One); - // A late error from either side must not deliver a second terminal. left.Observer.OnError(new InvalidOperationException("late")); right.Observer.OnError(new InvalidOperationException("late")); @@ -121,7 +119,6 @@ public async Task ForkJoinWitnessCompletesWithoutResultWhenASideIsEmpty() left.Observer!.OnNext(One); left.Observer.OnCompleted(); - // Right completes empty: no result, single completion. right.Observer!.OnCompleted(); await Assert.That(observer.Values).IsEmpty(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs index 28773c13..e0289f27 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs @@ -6,20 +6,13 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Tests for 's synchronous-completion path when the external -/// cancellation token forwards a terminal error while the task factory is still running: the subscription is -/// already completed by the time the completed task is inspected, so it forwards nothing further. -/// +/// Tests for the synchronous-completion path of . public sealed class FromAsyncSubscriptionTests { /// The value produced by the task factory that completes synchronously. private const int FactoryValue = 11; - /// - /// Verifies that when the external token is cancelled while the factory runs, the forwarded cancellation is - /// the only notification and the already-complete task the factory returned is not forwarded a second time. - /// + /// Cancellation raised while the factory runs is the only notification, and the task result is dropped. /// A task representing the asynchronous operation. [Test] public async Task ExternalCancellationDuringTheFactoryForwardsCancellationAndDropsTheCompletedResult() diff --git a/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs index de75b9b8..2b0d01fa 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs @@ -37,7 +37,7 @@ public class FromEnumerableSignalTests /// Expected five-six value sequence. private static readonly int[] ExpectedFiveSix = [Five, Six]; - /// Covers enumerable signal fast paths for arrays, read-only lists, iterators, and delegate subscriptions. + /// Verifies an enumerable signal emits values from arrays, lists, and iterators, then completes. /// A task representing the asynchronous operation. [Test] public async Task FromEnumerableSignalCoversAllSynchronousFastPaths() @@ -69,7 +69,7 @@ public async Task FromEnumerableSignalCoversAllSynchronousFastPaths() _ = Assert.Throws(() => arraySignal.Subscribe(static _ => { }, static ex => { }, null!)); } - /// Creates an iterator-backed enumerable for the non-indexable enumerable path. + /// Creates an iterator-backed enumerable that cannot be indexed. /// The yielded values. private static IEnumerable YieldValues() { diff --git a/src/tests/ReactiveUI.Primitives.Tests/IsEmptySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/IsEmptySignalTests.cs index d9deaad4..df1ee62f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/IsEmptySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/IsEmptySignalTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Tests for IsEmpty over an ordinary source, which does not require the current thread and therefore -/// settles on the direct subscribe path rather than the current-thread trampoline the timer sources use. -/// +/// Verifies IsEmpty reports whether an ordinary source produced a value before completing. public sealed class IsEmptySignalTests { /// The value produced by the non-empty source. diff --git a/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.CombineLatestTuples.cs b/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.CombineLatestTuples.cs index 95072e43..99fc85a4 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.CombineLatestTuples.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.CombineLatestTuples.cs @@ -273,11 +273,7 @@ public async Task CombineLatestTupleOverloadsRejectNullSources() await Assert.That(source2.HasObservers).IsFalse(); } - /// - /// Verifies a fully generic call passing an untyped null selector still binds to the selector overload. - /// That call shape fits both the arity-2 selector overload and the arity-3 tuple overload, so it is only - /// unambiguous while the tuple overloads keep their lower overload resolution priority. - /// + /// Verifies a fully generic call passing an untyped null selector binds to the selector overload. /// A task representing the asynchronous operation. [Test] [RequiresAssemblyFiles("Builds metadata references from loaded assembly locations.")] diff --git a/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs index eddcd39f..2adea242 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Tests for the infinite Loop signal, whose current-thread trampoline repeats a value until a bounding -/// operator disposes the subscription. A bounded loop must stop repeating once the bound is reached instead of -/// livelocking the subscribing thread. -/// +/// Verifies the infinite Loop signal repeats its value until a bounding operator stops it. public sealed class LoopSignalTests { /// The value repeated by the loop. diff --git a/src/tests/ReactiveUI.Primitives.Tests/MomentTests.cs b/src/tests/ReactiveUI.Primitives.Tests/MomentTests.cs index 150be74d..3fa16668 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/MomentTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/MomentTests.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies equality and hashing contracts. public class MomentTests { - /// The timestamp used by moment tests; only its stability matters. + /// A fixed timestamp shared by the moment tests. private static readonly DateTimeOffset SampleTimestamp = new(2026, 5, 24, 22, 52, 0, TimeSpan.Zero); /// Verifies a moment with a null value uses a deterministic null hash code. diff --git a/src/tests/ReactiveUI.Primitives.Tests/ObservableCancellationHandlingTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ObservableCancellationHandlingTests.cs index be9b9f14..1e7e6939 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ObservableCancellationHandlingTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ObservableCancellationHandlingTests.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Tests for the HandleCancellation operators on an observable source, which await the source through a -/// cancellable task and either return its value or, on cancellation, run the cleanup action and return the -/// default. -/// +/// Tests the HandleCancellation operators on an observable source. public sealed class ObservableCancellationHandlingTests { /// The value emitted by the source observable. @@ -26,7 +22,7 @@ public async Task HandleCancellationReturnsTheSourceValueWhenNotCancelled() await Assert.That(result).IsEqualTo(SourceValue); } - /// Verifies an already-cancelled token runs the cleanup action and yields the default value. + /// Verifies a pre-cancelled token runs the cleanup action and yields the default value. /// A task representing the asynchronous operation. [Test] public async Task HandleCancellationRunsTheCleanupActionAndReturnsDefaultWhenCancelled() diff --git a/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs index 4fd86be2..ea2281a0 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/OnErrorResumeNextSignalTests.cs @@ -181,7 +181,7 @@ private sealed class ThrowingEnumerable(Exception error) : IEnumerable GetEnumerator(); } - /// Enumerable that returns a null enumerator to cover the defensive null path. + /// Enumerable that returns a null enumerator. /// The source value type. /// Whether returns null. private sealed class NullEnumeratorEnumerable(bool returnsNull) : IEnumerable> diff --git a/src/tests/ReactiveUI.Primitives.Tests/OptionalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/OptionalTests.cs index d1d8840c..9379a533 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/OptionalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/OptionalTests.cs @@ -13,7 +13,7 @@ public class OptionalTests /// The second expected value. private const int Second = 2; - /// Covers optional value creation and empty value access. + /// Verifies an empty optional rejects value access and a created optional exposes its value. /// A task representing the asynchronous operation. [Test] public async Task OptionalCoversEmptyAndValueContracts() @@ -33,7 +33,7 @@ public async Task OptionalCoversEmptyAndValueContracts() await Assert.That(some.Value).IsEqualTo(Second); } - /// Covers optional conversion helpers and operators. + /// Verifies the optional conversion helpers and operators round-trip a value. /// A task representing the asynchronous operation. [Test] public async Task OptionalSupportsConversionHelpers() @@ -55,7 +55,7 @@ public async Task OptionalSupportsConversionHelpers() await Assert.That(some.Value).IsEqualTo(Second); } - /// Covers optional string formatting for values and empty values. + /// Verifies an optional formats its value, and an empty optional formats as none. /// A task representing the asynchronous operation. [Test] public async Task OptionalToStringFormatsValueAndNone() diff --git a/src/tests/ReactiveUI.Primitives.Tests/PriorityQueueTests.cs b/src/tests/ReactiveUI.Primitives.Tests/PriorityQueueTests.cs index 55a09b62..279b976e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/PriorityQueueTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/PriorityQueueTests.cs @@ -121,7 +121,7 @@ public async Task VerifyHeapPropertyDetectsMutablePriorityDrift() await Assert.That(rightQueue.VerifyHeapProperty()).IsFalse(); } - /// Covers indexed-item equality, hashing, and type mismatch handling. + /// Verifies indexed-item equality, hashing, and the comparison operators. /// A task representing the asynchronous operation. [Test] public async Task IndexedItemEqualityCoversContracts() @@ -162,7 +162,7 @@ private static PriorityQueue CreateMutableQueue( return queue; } - /// A mutable comparable item used to invalidate heap ordering after enqueue. + /// A comparable item whose priority can change after it is enqueued. /// The initial priority. private sealed class PriorityItem(int priority) : IComparable { diff --git a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs index 1e680f78..802da0c3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs @@ -299,7 +299,7 @@ public async Task ReentrantErrorWaitsForValueDelivery() await Assert.That(overlap).IsFalse(); } - /// A terminal notification arriving after the signal is already terminal is ignored. + /// A second error notification after the signal has terminated is ignored. /// A task representing the asynchronous operation. [Test] public async Task OnErrorAfterTerminalIsIgnored() @@ -316,7 +316,7 @@ public async Task OnErrorAfterTerminalIsIgnored() await Assert.That(observer.Errors[0]).IsSameReferenceAs(first); } - /// A throwing delivery still releases drain ownership so a later release can drain again. + /// A throwing delivery releases drain ownership so a later release drains again. /// A task representing the asynchronous operation. [Test] public async Task DrainReleasesOwnershipWhenDeliveryThrows() @@ -333,14 +333,14 @@ public async Task DrainReleasesOwnershipWhenDeliveryThrows() static _ => { }, static () => { }); - // Delivery throws out of the drain loop; the finally path must still release ownership. + // Delivery throws out of the drain loop; the finally path releases ownership. var first = Assert.Throws(() => signal.OnNext(FirstValue)); await Assert.That(first).IsSameReferenceAs(failure); - // Capacity is now exhausted, so this value only enqueues. + // Capacity is exhausted, so this value only enqueues. signal.OnNext(SecondValue); - // Releasing frees capacity and must begin a fresh drain, proving ownership was released. + // Releasing frees capacity and begins a fresh drain, so the delivery throws again. var second = Assert.Throws(signal.Release); await Assert.That(second).IsSameReferenceAs(failure); await Assert.That(deliveries).IsEqualTo(FirstDrainCount); diff --git a/src/tests/ReactiveUI.Primitives.Tests/R3BridgeGeneratorTests.cs b/src/tests/ReactiveUI.Primitives.Tests/R3BridgeGeneratorTests.cs index a53373cf..0594a688 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/R3BridgeGeneratorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/R3BridgeGeneratorTests.cs @@ -25,7 +25,7 @@ public class R3BridgeGeneratorTests /// Generated metadata attribute key. private const string GeneratedMetadataKey = "ReactiveUI.Primitives.R3Bridge.Generator"; - /// Legacy generated marker attribute type name. + /// The name of the marker attribute type the generator must not emit. private const string LegacyGeneratedMarkerName = "PrimitivesR3BridgeGeneratedAttribute"; /// Compiler diagnostic raised when a source type conflicts with an imported type. @@ -329,7 +329,7 @@ private static bool GeneratedMetadataExists(string[] generatedSources) => Array. generatedSources, static text => text.Contains($"AssemblyMetadata(\"{GeneratedMetadataKey}\"", StringComparison.Ordinal)); - /// Checks whether generated source contains the removed custom marker attribute type. + /// Checks whether generated source contains the custom marker attribute type. /// Generated source text to inspect. /// when the legacy generated marker type is emitted. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs index 55688d60..b609d5a3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs @@ -31,7 +31,7 @@ public class ReplaySignalTests /// The integer constant ten. private const int Ten = 10; - /// Constructors the argument checking. + /// Verifies the constructors reject negative buffer sizes, negative windows, and null sequencers. [Test] public void Constructor_ArgumentChecking() { @@ -51,7 +51,6 @@ public void Constructor_ArgumentChecking() _ = Assert.Throws(static () => CreateAndDispose(static () => new(TimeSpan.Zero, null!))); _ = Assert.Throws(static () => CreateAndDispose(static () => new(0, TimeSpan.Zero, null!))); - // zero allowed CreateAndDispose(static () => new(0)); CreateAndDispose(static () => new(TimeSpan.Zero)); CreateAndDispose(static () => new(0, TimeSpan.Zero)); @@ -83,7 +82,7 @@ public async Task AZeroSizedBufferReplaysNothingToALateSubscriber() await Assert.That(replayed.SequenceEqual([Three])).IsTrue(); } - /// Verifies a windowed buffer still drops the values that overflow its buffer size. + /// Verifies a windowed buffer drops the values that overflow its buffer size. /// A task representing the asynchronous operation. [Test] public async Task AWindowedBufferDropsValuesBeyondItsBufferSize() @@ -113,7 +112,7 @@ public async Task DisposingAReplaySubscriptionTwiceIsIdempotent() await Assert.That(replayed.Count).IsEqualTo(0); } - /// Determines whether this instance has observers. + /// Verifies every replay buffer shape tracks observers as subscriptions are added and removed. /// A task representing the asynchronous operation. [Test] public async Task HasObservers() @@ -124,7 +123,7 @@ public async Task HasObservers() await HasObserversImpl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } - /// Determines whether [has observers dispose1]. + /// Verifies every replay buffer shape drops its observers when the source is disposed first. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose1() @@ -135,7 +134,7 @@ public async Task HasObservers_Dispose1() await HasObservers_Dispose1Impl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } - /// Determines whether [has observers dispose2]. + /// Verifies every replay buffer shape drops its observers when the subscription is disposed first. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose2() @@ -146,7 +145,7 @@ public async Task HasObservers_Dispose2() await HasObservers_Dispose2Impl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } - /// Determines whether [has observers dispose3]. + /// Verifies every replay buffer shape reports disposal when it has no subscribers. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_Dispose3() @@ -157,7 +156,7 @@ public async Task HasObservers_Dispose3() await HasObservers_Dispose3Impl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } - /// Determines whether [has observers on completed]. + /// Verifies completion drops the observers of every replay buffer shape. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_OnCompleted() @@ -168,7 +167,7 @@ public async Task HasObservers_OnCompleted() await HasObservers_OnCompletedImpl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } - /// Determines whether [has observers on error]. + /// Verifies an error drops the observers of every replay buffer shape. /// A task representing the asynchronous operation. [Test] public async Task HasObservers_OnError() @@ -179,7 +178,7 @@ public async Task HasObservers_OnError() await HasObservers_OnErrorImpl(new(TimeSpan.FromSeconds(1), EmptySequencer.Instance)); } - /// Called when [error argument checking]. + /// Verifies a replay signal rejects a null error. [Test] public void OnError_ArgumentChecking() { @@ -189,7 +188,7 @@ public void OnError_ArgumentChecking() _ = Assert.Throws(static () => new ReplaySignal(EmptySequencer.Instance).OnError(null!)); } - /// Subscribes the argument checking. + /// Verifies a replay signal rejects a null observer. [Test] public void Subscribe_ArgumentChecking() { @@ -199,7 +198,7 @@ public void Subscribe_ArgumentChecking() _ = Assert.Throws(static () => new ReplaySignal(EmptySequencer.Instance).Subscribe(null!)); } - /// Verifies subjects, replay, behavior, state, and connectable aliases cover late terminal branches. + /// Verifies late subscribers receive the buffered values and the first terminal notification. /// A task representing the asynchronous operation. [Test] public async Task SubjectsReplayBehaviorStateAndConnectableAliasesCoverLateTerminalBranches() diff --git a/src/tests/ReactiveUI.Primitives.Tests/ResultTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ResultTests.cs index 5bc455b5..d0ce0e0f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ResultTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ResultTests.cs @@ -12,7 +12,7 @@ public class ResultTests /// Failure message used by result tests. private const string FailureMessage = "boom"; - /// Covers the success result contract. + /// Verifies a success result reports success with no exception. /// A task representing the asynchronous operation. [Test] public async Task ResultSuccessReportsStatusAndString() @@ -25,7 +25,7 @@ public async Task ResultSuccessReportsStatusAndString() await Assert.That(result.ToString()).IsEqualTo("Success"); } - /// Covers the failure result contract. + /// Verifies a failure result reports failure and carries its exception. /// A task representing the asynchronous operation. [Test] public async Task ResultFailureReportsExceptionStatusAndString() @@ -41,7 +41,7 @@ public async Task ResultFailureReportsExceptionStatusAndString() await Assert.That(created.Exception).IsSameReferenceAs(error); } - /// Covers failure result argument validation. + /// Verifies a result rejects a null exception. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void ResultRejectsNullException() => diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.AliasOverloads.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.AliasOverloads.cs index 826bf483..76a95833 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.AliasOverloads.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.AliasOverloads.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the System.Reactive alias overloads that take only a subset of the observer callbacks, an explicit -/// comparer, or an explicit gate — the shapes the data-driven parity rows do not reach. -/// +/// Verifies the System.Reactive alias overloads taking a callback subset, an explicit comparer, or a gate. public partial class RxNamesTests { /// Verifies the two-callback Do overloads run only the callbacks they were given. diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs index d4c5c63a..6bfbf32d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Helpers.cs @@ -17,10 +17,7 @@ public partial class RxNamesTests /// The narrowest generated multi-source CombineLatest overload. private const int MinMultiSourceArity = 4; - /// - /// The generated multi-source CombineLatest builders, ordered by arity from - /// upwards, so an arity selects its overload without a literal case label. - /// + /// The generated multi-source CombineLatest builders, ordered by arity from upwards. private static readonly Func>[] _combineLatestBuilders = [ CombineLatestOfFour, @@ -127,7 +124,7 @@ private static bool RunStatefulThrow(Func, IObservable> op return error is InvalidOperationException; } - /// A stateful projection that always throws (drives the sink catch path). + /// A stateful projection that always throws. /// The unused state. /// The unused value. /// Never returns; always throws. @@ -135,7 +132,7 @@ private static bool RunStatefulThrow(Func, IObservable> op [SuppressMessage("Maintainability", "SST1461:Remove unread private parameters", Justification = "The signature is fixed by the delegate this method is passed to as a method group.")] private static int ThrowProjection(int state, int value) => throw new InvalidOperationException(Boom); - /// A stateful predicate that always throws (drives the sink catch path). + /// A stateful predicate that always throws. /// The unused state. /// The unused value. /// Never returns; always throws. @@ -159,7 +156,7 @@ private static List RunSampling(Func, ISequencer, IObserva return values; } - /// Combines a source value with an inner value (result selector for the 3-arg SelectMany/FlatMap). + /// Combines a source value with an inner value. /// The source value. /// The inner value. /// The combined value. @@ -508,7 +505,7 @@ private static List Collect(IObservable source) return values; } - /// Builds a source of two int-range inner sources (exercises the synchronous Switch range fast path). + /// Builds a source of two int-range inner sources. /// An outer source of two range inners. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static IObservable> RangeInners() => @@ -534,16 +531,11 @@ private static bool RunStopGuards(Func, IObservable> op) return completed == One && error is null; } - /// - /// The sources of the widest generated CombineLatest overload, named by argument position so every arity can - /// be built without indexing into an array. - /// + /// The sources of the widest generated CombineLatest overload, named by argument position. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2315:A type that owns a disposable should be disposable", - Justification = - "Test helper that holds the source subjects for a CombineLatest arity test. The subjects live for the " - + "duration of the test and the test process owns them; this helper is deliberately not IDisposable.")] + Justification = "The test process owns these subjects for the lifetime of the test.")] private sealed class CombineLatestSources { /// Initializes a new instance of the class. @@ -649,7 +641,7 @@ public IDisposable Subscribe(IObserver observer) public void Complete() => _observer?.OnCompleted(); } - /// A source that reports it requires current-thread subscription (drives the sink's propagation check). + /// A source that reports it requires current-thread subscription. /// The element type. private sealed class CurrentThreadSource : IRequireCurrentThread { diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.SubscribeSafe.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.SubscribeSafe.cs index 491506e3..74050125 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.SubscribeSafe.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.SubscribeSafe.cs @@ -401,9 +401,9 @@ public async Task SubscribeSafePrimitivesDeliversTheSameNotificationsAsSubscribe } /// - /// Verifies the observer-taking SubscribeSafe is ambiguous once System.Reactive is in scope, and that - /// the explicit Primitives name resolves. System.Reactive declares its own observer overload in the - /// System namespace, which an implicit using System; always brings along. + /// Verifies the explicit Primitives name resolves where the observer-taking SubscribeSafe is ambiguous. + /// System.Reactive declares a competing observer overload in the System namespace, which an implicit + /// using System; brings along. /// /// A task representing the asynchronous operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs index ec4fa688..5aa743f1 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs @@ -201,7 +201,7 @@ public static IEnumerable BinaryCases() _latched); } - /// Provides the generated multi-source CombineLatest arities not covered by the dedicated edge tests. + /// Provides the arities of the generated multi-source CombineLatest overloads. /// The CombineLatest arities from 4 through 15. public static IEnumerable MultiSourceCombineLatestArities() { @@ -656,7 +656,7 @@ public void RxNamesThrowOnNegativeArguments() _ = Assert.Throws(() => source.Sample(TimeSpan.FromTicks(NegativeOne))); } - /// Verifies the stateful sinks forward a value and then an error (covers their error path). + /// Verifies the stateful sinks forward a value and then an error. /// A task representing the asynchronous operation. [Test] public async Task StatefulSinksForwardValueThenError() @@ -666,7 +666,7 @@ public async Task StatefulSinksForwardValueThenError() await Assert.That(RunStatefulError(static s => s.DoWith(Ten, IgnoreState))).IsTrue(); } - /// Verifies the stateful projection sinks forward an exception thrown by the projection (covers their catch path). + /// Verifies the stateful projection sinks forward an exception thrown by the projection. /// A task representing the asynchronous operation. [Test] public async Task StatefulProjectionForwardsThrownError() @@ -721,7 +721,7 @@ public async Task ResumeDisposeStopsForwarding() await Assert.That(values.SequenceEqual(_tenOnly)).IsTrue(); } - /// Verifies Sample mirrors Probe when sampled against an identical virtual clock drive. + /// Verifies Sample mirrors Probe. /// A task representing the asynchronous operation. [Test] public async Task SampleMatchesProbe() @@ -764,7 +764,7 @@ public async Task RxNamesRangeFastPathsMatchCounterparts() await Assert.That(switched).IsEquivalentTo(switchedTo, EqualityComparer.Default); } - /// Verifies Retry mirrors the source when no error occurs (covers the happy path). + /// Verifies Retry mirrors the source when no error occurs. /// A task representing the asynchronous operation. [Test] public async Task RetryMirrorsSourceWhenNoError() => @@ -826,7 +826,7 @@ public void ResumeThrowsOnNullObserver() => .Resume(Signal.FromEnumerable(_oneToThree)) .Subscribe((IObserver)null!)); - /// Verifies Resume takes the scheduled subscription path when a current-thread sequencer is already active. + /// Verifies Resume schedules its subscription when a current-thread sequencer is active. /// A task representing the asynchronous operation. [Test] public async Task ResumeSchedulesWhenCurrentThreadSequencerActive() @@ -855,7 +855,7 @@ await Assert.That(new ResumeSignal(Signal.FromEnumerable(_oneToThree), Sign /// The new accumulated value. private static int Add(int accumulated, int value) => accumulated + value; - /// Returns the value unchanged (key selector). + /// Returns the value unchanged. /// The source value. /// The value. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -873,11 +873,11 @@ await Assert.That(new ResumeSignal(Signal.FromEnumerable(_oneToThree), Sign /// when the value is a multiple of the divisor. private static bool IsMultiple(int divisor, int value) => value % divisor == 0; - /// Consumes a value without effect (the side-effect under test is irrelevant to the output). + /// Consumes a value without effect. /// The source value, which the side effect deliberately ignores. private static void Ignore(int _) { - // Intentionally empty: Do/Tap forward values unchanged regardless of the side effect. + // Intentionally empty. } /// Consumes a state and value without effect. @@ -886,7 +886,7 @@ private static void Ignore(int _) [SuppressMessage("Maintainability", "SST1461:Remove unread private parameters", Justification = "The signature is fixed by the delegate this method is passed to as a method group.")] private static void IgnoreState(int state, int value) { - // Intentionally empty: DoWith/TapWith forward values unchanged regardless of the side effect. + // Intentionally empty. } /// Projects a value to an inner sequence that emits it twice. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs index 652a9bea..e08b86dd 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SafeWitnessTests.cs @@ -16,11 +16,7 @@ public class SafeWitnessTests /// A value pushed after the witness has terminated; it must never be delivered. private const int LateValue = 2; - /// - /// The cancel-owning wrapper forwards the first fault, disposes its cancellation resource once, and drops - /// every later notification. Faulting first (rather than completing first) is the path that leaves the - /// wrapped observer holding an error it must not see twice. - /// + /// The cancel-owning wrapper forwards the first fault, disposes its cancel resource once, then drops everything after. /// A task representing the asynchronous operation. [Test] public async Task CancelOwningSafeWitnessForwardsTheFirstFaultThenGoesQuiet() @@ -42,10 +38,7 @@ public async Task CancelOwningSafeWitnessForwardsTheFirstFaultThenGoesQuiet() await Assert.That(cancelled).IsEqualTo(1); } - /// - /// A delegate witness passed to is made safe in place rather - /// than wrapped. It must still forward the first fault exactly once and ignore everything after it. - /// + /// A delegate witness made safe by forwards the first fault exactly once, then ignores the rest. /// A task representing the asynchronous operation. [Test] public async Task DelegateWitnessMadeSafeForwardsTheFirstFaultThenGoesQuiet() @@ -67,10 +60,7 @@ public async Task DelegateWitnessMadeSafeForwardsTheFirstFaultThenGoesQuiet() await Assert.That(values.Count).IsEqualTo(0); } - /// - /// When a safe delegate witness's value callback throws, the witness latches stopped: the exception - /// surfaces to the caller once, and the next value is dropped instead of re-entering the broken callback. - /// + /// A throwing value callback stops the safe delegate witness: the exception surfaces once and the next value is dropped. /// A task representing the asynchronous operation. [Test] public async Task DelegateWitnessMadeSafeStopsAfterItsValueCallbackThrows() @@ -91,10 +81,7 @@ public async Task DelegateWitnessMadeSafeStopsAfterItsValueCallbackThrows() await Assert.That(calls).IsEqualTo(1); } - /// - /// The cancel-free wrapper (a plain observer made safe without a cancellation resource) forwards values, - /// completes exactly once, and drops notifications that arrive after the completion. - /// + /// The cancel-free wrapper forwards values, completes exactly once, and drops notifications after the completion. /// A task representing the asynchronous operation. [Test] public async Task CancelFreeSafeWitnessForwardsValuesThenCompletesExactlyOnce() @@ -133,10 +120,7 @@ public async Task CancelFreeSafeWitnessForwardsTheFirstFaultThenGoesQuiet() await Assert.That(observer.Values.Count).IsEqualTo(0); } - /// - /// A throwing value callback stops the cancel-free wrapper: the exception surfaces once and the next value - /// is dropped rather than handed to the observer that already failed. - /// + /// A throwing observer stops the cancel-free wrapper: the exception surfaces once and the next value is dropped. /// A task representing the asynchronous operation. [Test] public async Task CancelFreeSafeWitnessStopsAfterTheObserverThrows() diff --git a/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs index f869ddd0..799afeaf 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ScheduledSignalTests.cs @@ -33,7 +33,7 @@ public sealed class ScheduledSignalTests /// The expected terminal exception message. private const string TerminalThrowMessage = "terminal"; - /// The default observer receives the original terminal event and the restored replay. + /// The terminal notification count the default observer receives across completion and replay. private const int DefaultObserverTerminalReplayCount = 2; /// Constructor validates the scheduler argument. @@ -160,7 +160,7 @@ await Assert.That(() => signal.OnError(new InvalidOperationException())) .ThrowsExactly(); } - /// The protected dispose path marks the signal disposed when managed cleanup is not requested. + /// The protected dispose overload marks the signal disposed when managed cleanup is not requested. /// A task representing the asynchronous operation. [Test] public async Task DisposeWithoutManagedCleanupMarksSignalDisposed() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs index f3c9b7a0..20c2760f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Pools.cs @@ -258,7 +258,7 @@ public override void Post(SendOrPostCallback d, object? state) } } - /// Task scheduler that runs queued work inline and keeps the last task so a test can read its outcome. + /// Task scheduler that runs queued work inline and keeps the most recently queued task. private sealed class InlineTaskScheduler : TaskScheduler { /// Gets the most recently queued task. @@ -278,7 +278,7 @@ protected override void QueueTask(Task task) protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; } - /// Work item that always faults, so a sequencer's failure routing can be observed. + /// Work item that throws when executed. private sealed class ThrowingWorkItem : IWorkItem { /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs index b73c09bd..7b51d706 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.VirtualTime.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the virtual-time sequencers: the stateful scheduling overloads, the guards that stop the clock being -/// re-entered while it is running, and a sequencer built on a clock type of the caller's choosing. -/// +/// Verifies virtual-time scheduling overloads, re-entrancy guards, and caller-supplied clock types. public partial class SequencerTests { /// Clock value a virtual sequencer starts at. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs index b55075fa..e387c2d7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs @@ -7,16 +7,13 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the work-item shapes the sequencers queue: the run-once handshake, cancellation before and after a run, -/// and the delay conversion that decides when a due item runs. -/// +/// Verifies the queued work-item shapes: run-once execution, cancellation, and delay conversion. public partial class SequencerTests { - /// A monotonic timestamp delta used to drive the delay conversions. + /// A monotonic timestamp delta that drives the delay conversions. private const long DueTimestamp = 1000; - /// Verifies a monotonic delta that has already elapsed converts to no delay at all. + /// Verifies a monotonic delta at or before the current instant converts to no delay at all. /// A task representing the asynchronous operation. [Test] public async Task TimestampDeltaAtOrBeforeNowConvertsToZeroDelay() @@ -105,7 +102,6 @@ public async Task ScheduledWorkItemReleasesItsActionResultOnCancellation() item.Dispose(); await Assert.That(disposed).IsEqualTo(1); - // Disposal is a single transition: a second cancel must not dispose the action's result twice. item.Dispose(); await Assert.That(disposed).IsEqualTo(1); await Assert.That(item.IsDisposed).IsTrue(); @@ -187,7 +183,6 @@ public async Task TimedWorkItemComparesByWorkItemIdentityAndDueTimestamp() await Assert.That(item.Equals(otherItem)).IsFalse(); await Assert.That(item.Equals(otherDueTimestamp)).IsFalse(); - // The boxed overload agrees with the strongly typed one, and rejects anything that is not an entry. await Assert.That(item.Equals((object)same)).IsTrue(); await Assert.That(item.Equals((object)otherItem)).IsFalse(); await Assert.That(item.Equals(new object())).IsFalse(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs index fafe8ba7..aaea38ed 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs @@ -76,11 +76,7 @@ public async Task CurrentThreadSequencerQueuesNestedWorkUntilCurrentActionComple await Assert.That(calls.SequenceEqual(expected)).IsTrue(); } - /// - /// Verifies nested current-thread work cancelled before the trampoline reaches it never runs. Scheduling from - /// inside a running action queues a cancellable work item and hands its handle back; disposing that handle must - /// leave the trampoline nothing to execute. - /// + /// Verifies nested current-thread work cancelled before the trampoline reaches it never runs. /// A task representing the asynchronous operation. [Test] public async Task CurrentThreadSequencerSkipsNestedWorkCancelledBeforeTheTrampolineRunsIt() @@ -88,7 +84,7 @@ public async Task CurrentThreadSequencerSkipsNestedWorkCancelledBeforeTheTrampol var innerRan = false; _ = Sequencer.CurrentThread.Schedule(() => { - // Re-entrant scheduling queues an ActionWorkItem and returns its cancellation handle. + // Re-entrant scheduling queues the work rather than running it inline. var handle = Sequencer.CurrentThread.Schedule(() => innerRan = true); handle.Dispose(); }); @@ -179,7 +175,7 @@ static void CreateScheduledItem( ScheduledItem.Create(scheduler, state, action, DateTimeOffset.UnixEpoch)); } - /// Covers priority-queue ordering, shrink, peek, and removal branches. + /// Priority queues order by value, support peek and removal, shrink when drained, and reject a negative capacity. /// A task representing the asynchronous operation. [Test] public async Task PriorityQueuesCoverOrderingShrinkAndRemovalBranches() @@ -209,7 +205,7 @@ public async Task PriorityQueuesCoverOrderingShrinkAndRemovalBranches() _ = Assert.Throws(CreateInvalidSequencerQueue); } - /// Covers scheduled-item comparison, invocation, disposal, and clock branches. + /// Scheduled items compare by due time, queue in due order, and repeat invocation and disposal. /// A task representing the asynchronous operation. [Test] public async Task ScheduledItemsCoverComparisonInvocationAndDisposalBranches() @@ -261,7 +257,7 @@ public async Task ScheduledItemsCoverComparisonInvocationAndDisposalBranches() await AssertVirtualClockStartsAtItsInitialTime(); } - /// Covers immediate and background sequencer argument validation and execution paths. + /// Immediate and background sequencers validate their arguments and run scheduled work. /// A task representing the asynchronous test operation. [Test] public async Task SequencersCoverValidationAndExecutionBranches() @@ -276,7 +272,7 @@ public async Task SequencersCoverValidationAndExecutionBranches() await AssertSynchronizationContextSequencerValidatesAndRunsScheduledWork(); } - /// Covers virtual-time extension validation and action scheduling. + /// Virtual-time extensions reject missing arguments and run relative and absolute actions. /// A task representing the asynchronous operation. [Test] public async Task VirtualTimeSequencerExtensionsValidateAndRunActions() @@ -303,7 +299,7 @@ public async Task VirtualTimeSequencerExtensionsValidateAndRunActions() await Assert.That(invoked).IsEqualTo(Three); } - /// Covers simple sequencer extension validation, delayed overloads, state overloads, and recursive scheduling. + /// Simple sequencer extensions reject missing arguments and run delayed, stateful, and recursive work. /// A task representing the asynchronous operation. [Test] public async Task SimpleSequencerExtensionsCoverValidationAndRecursiveScheduling() @@ -366,7 +362,7 @@ public async Task SimpleSequencerExtensionsCoverValidationAndRecursiveScheduling await Assert.That(recursiveCount).IsEqualTo(Three); } - /// Covers virtual-time service lookup, stopwatch, stop, sleep, and nested-run guard paths. + /// A virtual clock exposes its stopwatch provider, sleeps, stops, and refuses to be advanced from running work. /// A task representing the asynchronous operation. [Test] public async Task VirtualTimeSequencerBaseCoversServicesStopwatchAndRunGuards() @@ -392,7 +388,7 @@ public async Task VirtualTimeSequencerBaseCoversServicesStopwatchAndRunGuards() await Assert.That(events.SequenceEqual(VirtualEventsExpected)).IsTrue(); } - /// Covers timestamp scheduling work-item argument validation. + /// Timestamp scheduling rejects a missing work item. [Test] public void ScheduleWithTimestampValidatesWorkItem() { @@ -400,7 +396,7 @@ public void ScheduleWithTimestampValidatesWorkItem() _ = Assert.Throws(() => sequencer.Schedule(null!, long.MaxValue)); } - /// Covers timestamp scheduling executing due and past-due work items. + /// Timestamp scheduling runs due and past-due work items. /// A task representing the asynchronous operation. [Test] public async Task ScheduleWithTimestampExecutesDueAndPastDueWork() @@ -413,7 +409,7 @@ public async Task ScheduleWithTimestampExecutesDueAndPastDueWork() await Assert.That(workItem.ExecuteCount).IsEqualTo(Two); } - /// Covers scheduled-item probe comparison, equality, and invocation branches. + /// A scheduled item compares, equates, hashes, and disposes its invocation result when cancelled. /// A task representing the asynchronous operation. [Test] public async Task ScheduledProbeComparisonAndInvocationCoverContracts() @@ -431,11 +427,7 @@ public async Task ScheduledProbeComparisonAndInvocationCoverContracts() await Assert.That(scheduledDisposed).IsTrue(); } - /// - /// The non-generic comparison is what a non-generic sorted collection reaches for. It must order by due time - /// just like the typed one, treat as ordering first, and refuse anything that is not - /// a scheduled item rather than silently claiming equality. - /// + /// The non-generic comparison orders by due time, sorts first, and rejects a non-item. /// A task representing the asynchronous operation. [Test] public async Task ScheduledItemNonGenericComparisonOrdersByDueTime() @@ -444,8 +436,7 @@ public async Task ScheduledItemNonGenericComparisonOrdersByDueTime() var late = ScheduledProbe.Create(Two, static () => EmptyDisposable.Instance); var sameDueTime = ScheduledProbe.Create(One, static () => EmptyDisposable.Instance); - // The casts are what force the non-generic IComparable.CompareTo(object) overload: an - // unqualified call would bind to the strongly typed CompareTo(ScheduledItem) instead. + // The casts force the non-generic IComparable.CompareTo(object) overload. await Assert.That(((IComparable)early).CompareTo(null)).IsEqualTo(One); await Assert.That(((IComparable)early).CompareTo(late) < 0).IsTrue(); await Assert.That(((IComparable)late).CompareTo(early) > 0).IsTrue(); @@ -622,7 +613,7 @@ private static void CreateSynchronizationContextSequencerWithoutContext() GC.KeepAlive(invalid); } - /// Completes a task completion source or throws when it was already completed. + /// Completes a task completion source, rejecting a second completion. /// The completion source to update. /// The value to set. /// was already completed. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalCollectTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalCollectTests.cs index da28f7ec..32759312 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalCollectTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalCollectTests.cs @@ -13,10 +13,7 @@ public sealed class SignalCollectTests /// The buffer window used by the manually driven flush tests. private static readonly TimeSpan CollectWindow = TimeSpan.FromMilliseconds(50); - /// - /// Verifies a flush that fires with an empty window emits no batch, and that a source which completes - /// twice completes the buffered sequence once. - /// + /// A stale flush emits no batch, and a source that completes twice completes the batched sequence once. /// A task representing the asynchronous operation. [Test] public async Task CollectIgnoresAStaleFlushAndASecondCompletion() @@ -40,7 +37,7 @@ public async Task CollectIgnoresAStaleFlushAndASecondCompletion() await Assert.That(batches.Count).IsEqualTo(1); } - /// Verifies the Collect method covers immediate, scheduled, terminal, and error paths. + /// Collect emits immediately with a zero window, batches on a clock, and forwards completion and errors. /// A task representing the asynchronous operation. [Test] public async Task CollectCoversImmediateScheduledCompletionErrorAndDisposePaths() @@ -111,7 +108,6 @@ public async Task BufferEmitsTheWindowBatchOnceTheWindowElapses() source.OnNext(First); source.OnNext(Second); - // The window has not elapsed, so nothing may have been emitted yet. await Assert.That(batches.Count).IsEqualTo(0); clock.AdvanceBy(TimeSpan.FromTicks(Second)); @@ -119,7 +115,6 @@ public async Task BufferEmitsTheWindowBatchOnceTheWindowElapses() await Assert.That(batches.Count).IsEqualTo(1); await Assert.That(batches[0].SequenceEqual([First, Second])).IsTrue(); - // A value in the next window opens a fresh batch rather than re-emitting the previous one. source.OnNext(Third); clock.AdvanceBy(TimeSpan.FromTicks(Second)); @@ -150,7 +145,7 @@ public async Task BufferForwardsASourceErrorAndAbandonsThePendingWindow() await Assert.That(observed.Count).IsEqualTo(1); await Assert.That(observed[0]).IsSameReferenceAs(expected); - // The buffer has stopped, so a late value must not be recorded and the scheduled flush must emit nothing. + // The source keeps pushing after its error, which the stopped buffer must drop. upstream.OnNext(Second); clock.AdvanceBy(TimeSpan.FromTicks(Second)); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs index 4f51298d..0db4e003 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalCreateTests.cs @@ -31,7 +31,7 @@ public class SignalCreateTests /// Expected values for create-with-state tests. private static readonly int[] CreateWithStateExpected = [Third]; - /// Creates the argument checking. + /// The create factory rejects a null subscribe callback and a null observer. [Test] public void Create_ArgumentChecking() { @@ -40,7 +40,7 @@ public void Create_ArgumentChecking() Signal.Create((Func, IDisposable>)null!).Subscribe(null!)); } - /// Creates the null coalescing action. + /// A create subscription disposes cleanly when its disposable carries no action. /// A task representing the asynchronous operation. [Test] public async Task Create_NullCoalescingAction() @@ -56,14 +56,14 @@ public async Task Create_NullCoalescingAction() await Assert.That(lst.SequenceEqual([CreatedValue])).IsTrue(); } - /// Creates the exception. + /// A subscribe callback that throws surfaces at the subscribe call. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void Create_Exception() => Assert.Throws(static () => Signal.Create( new Func, IDisposable>(static _ => throw new InvalidOperationException())) .Subscribe()); - /// Creates the observer throws. + /// An observer that throws from a notification surfaces the failure at the subscribe call. [Test] public void Create_ObserverThrows() { @@ -84,7 +84,7 @@ public void Create_ObserverThrows() }).Subscribe(static x => { }, static ex => { }, static () => throw new InvalidOperationException())); } - /// Creates the with disposable argument checking. + /// The disposable-returning create overload rejects a null callback and a null observer. [Test] public void CreateWithDisposable_ArgumentChecking() { @@ -98,7 +98,7 @@ public void CreateWithDisposable_ArgumentChecking() }).Subscribe(null!)); } - /// Creates the with disposable null coalescing action. + /// The disposable-returning create overload tolerates a callback that returns no disposable. /// A task representing the asynchronous operation. [Test] public async Task CreateWithDisposable_NullCoalescingAction() @@ -114,16 +114,14 @@ public async Task CreateWithDisposable_NullCoalescingAction() await Assert.That(lst.SequenceEqual([CreatedValue])).IsTrue(); } - /// Creates the with disposable exception. + /// A throwing subscribe callback surfaces at the subscribe call of the disposable-returning overload. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", Justification = - "A separate named regression test covering the disposable-returning Create overload's subscribe-time throw. " - + "It shares its assertion shape with Create_Exception but is kept as its own [Test] so the two entry points " - + "have independent, named coverage.")] + "The two tests subscribe through different Create overloads.")] public void CreateWithDisposable_Exception() => Assert.Throws(static () => Signal.Create( new Func, IDisposable>(static _ => throw new InvalidOperationException())) .Subscribe()); @@ -176,7 +174,7 @@ public async Task AnonymousSignal_ReturnsEmptyDisposableForNullDelegateResult() await Assert.That(disposable).IsSameReferenceAs(EmptyDisposable.Instance); } - /// Covers create-with-state overloads and null validation. + /// The create-with-state overloads invoke their stateful callback and reject null arguments. /// A task representing the asynchronous operation. [Test] public async Task CreateWithStateFactoriesInvokeStatefulSubscribeCallbacks() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalEmitIfQuietTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalEmitIfQuietTests.cs index 94c3c958..66afbc3f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalEmitIfQuietTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalEmitIfQuietTests.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies emit-if-quiet debounce contracts. public sealed class SignalEmitIfQuietTests { - /// - /// Verifies a source that completes twice, or errors after completing, is not forwarded twice: the quiet - /// observer keeps the Rx grammar even when the source breaks it. - /// + /// A second completion, or an error after completing, is dropped rather than forwarded. /// A task representing the asynchronous operation. [Test] public async Task EmitIfQuietDropsTerminalNotificationsAfterTheFirstOne() @@ -32,7 +29,7 @@ public async Task EmitIfQuietDropsTerminalNotificationsAfterTheFirstOne() await Assert.That(witness.Values.Count).IsEqualTo(0); } - /// Verifies the EmitIfQuiet method covers immediate, scheduled, completion, stale emission, and error paths. + /// EmitIfQuiet emits immediately with a zero window, emits the last value after a quiet period, and forwards errors. /// A task representing the asynchronous operation. [Test] public async Task EmitIfQuietCoversImmediateScheduledCompletionStaleAndErrorPaths() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs index b6978cfa..08e0754c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Behavior.cs @@ -23,7 +23,7 @@ public partial class SignalFactoriesTests /// Expected single async value. private static readonly int[] ExpectedSingleAsyncValue = [1]; - /// Exercises task, async-enumerable, and terminal task branches. + /// Task and async-enumerable factories forward cancellation and faults, and terminal tasks fault on an empty source. /// A task representing the asynchronous test. [Test] public async Task FactoriesTasksAndTerminalTasksCoverCancellationFaultAndEmptyBranches() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs index dd44f4e3..21431ff9 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs @@ -364,7 +364,7 @@ public async Task TimeFactoriesUseInjectedScheduler() await Assert.That(every.SequenceEqual(EveryExpected)).IsTrue(); } - /// Verifies additional factory and unary operator parity helpers. + /// Sequence boundary, boolean terminal, and selection operators emit their expected values. /// A task representing the asynchronous operation. [Test] public async Task AdditionalFactoriesAndUnaryOperatorsCoverCommonParitySurface() @@ -374,7 +374,7 @@ public async Task AdditionalFactoriesAndUnaryOperatorsCoverCommonParitySurface() await VerifySelectionAndProjectionOperators(); } - /// Verifies System.Reactive-style aliases intended to ease migration. + /// System.Reactive-named aliases prepend, tap, recover, and observe on a sequencer. /// A task that completes when the asynchronous assertions have run. [Test] public async Task SystemReactiveNamedAliasesCoverMigrationConvenienceSurface() @@ -660,7 +660,7 @@ private static async Task VerifySequenceBoundaryOperators() "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The assertions target the synchronous IObservable operator overloads.")] private static async Task VerifyBooleanTerminalOperators() { List count = []; diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs index 8387f54f..ee36c617 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs @@ -25,7 +25,7 @@ public async Task AnEmptySequenceCompletesWithoutEmitting() await Assert.That(completions).IsEqualTo(1); } - /// Verifies the cancellable enumerable factory stops when its token is already cancelled. + /// The enumerable factory stops for a cancelled token and emits its whole sequence for an uncancellable one. /// A task representing the asynchronous operation. [Test] public async Task FromEnumerableHonorsACancellableTokenAndIgnoresAnUncancellableOne() @@ -72,8 +72,7 @@ public async Task StartWithoutASequencerRunsTheWorkOnTheDefaultSequencer() var actionRuns = 0; AwaitableWitness actionWitness = new(); - // A void method group is what selects Start(Action); a lambda over 'actionRuns++' is a - // Func and would bind to the generic Start overload instead. + // A void method group selects Start(Action); a lambda over 'actionRuns++' would bind to Start. void RunAction() => actionRuns++; using var actionSubscription = Signal.Start(RunAction).Subscribe(actionWitness); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs index 78848d5c..8dff500b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs @@ -98,10 +98,7 @@ await Assert.That(static () => Signal.FromEventPattern, null!)).Throws(); } - /// - /// Verifies the handler-pair constructor attaches on the subscribing thread. The factories all supply a - /// sequencer argument now, so this is the shape only a caller constructing the signal directly reaches. - /// + /// Verifies the handler-pair constructor attaches on the subscribing thread. /// A task representing the asynchronous operation. [Test] public async Task FromEventPatternSignalWithoutASequencerAttachesOnTheSubscribingThread() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs index dac4e5f5..e46811df 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs @@ -157,7 +157,6 @@ public async Task PeriodicAfterStopsReschedulingWhenTheSubscriptionIsDisposed() await Assert.That(witness.Values.SequenceEqual(ExpectedSingleZeroTick)).IsTrue(); await Assert.That(witness.Completed).IsEqualTo(1); - // The downstream tore the timer down on its first tick, so no further tick may be scheduled. clock.AdvanceBy(SingleTick); clock.AdvanceBy(SingleTick); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs index 762810ef..fe91ee53 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.cs @@ -61,7 +61,7 @@ public partial class SignalFactoriesTests /// The expected repeated five values emitted by the bounded loop factory. private static readonly int[] ExpectedFiveFive = [Five, Five]; - /// The expected single seven produced by the start and scheduled branches. + /// The expected single seven produced by the start and scheduled factories. private static readonly int[] ExpectedSingleSeven = [Seven]; /// The expected error type names produced by the task factory continuations. @@ -89,7 +89,7 @@ public partial class SignalFactoriesTests /// The expected values emitted by the direct task runner tests. private static readonly int[] ExpectedOneTwoThree = [One, Two, Three]; - /// Covers scheduled return, throw, and empty signal implementations. + /// Scheduled emit, none, and fail factories deliver a value, a completion, and the original error. /// A task representing the asynchronous operation. [Test] public async Task ScheduledScalarFactoriesUseNonImmediateSignalImplementations() @@ -110,7 +110,7 @@ public async Task ScheduledScalarFactoriesUseNonImmediateSignalImplementations() await Assert.That(thrown[0]).IsSameReferenceAs(error); } - /// Covers factory scheduling, task continuations, and timer aliases with deterministic time. + /// Scheduled ranges, loops, task factories, and timer aliases emit their sequences on a virtual clock. /// A task that completes when asynchronous continuations are observed. [Test] public async Task FactoryAliasesScheduledRangesTasksAndTimersCoverRemainderBranches() @@ -161,7 +161,7 @@ public async Task FactoryAliasesScheduledRangesTasksAndTimersCoverRemainderBranc AssertSchedulingFactoriesRejectInvalidArguments(); } - /// Covers small value/factory/inline branches with public surface behavior. + /// Scheduled, looped, and paired signals validate their observers and emit through the inline surface. /// A task representing the asynchronous operation. [Test] public async Task ValueFactoryAndInlineBranchesCoverPublicEdgeBehavior() @@ -211,7 +211,7 @@ public async Task ValueFactoryAndInlineBranchesCoverPublicEdgeBehavior() await Assert.That(mappedErrors.SequenceEqual(ExpectedMappedErrors)).IsTrue(); } - /// Covers create-with-state, defer, and immediate-throw factory observer error paths. + /// Create-with-state, lazy, and fail factories forward their failures to the observer. /// A task representing the asynchronous operation. [Test] public async Task FactoryErrorPathsForwardObserverErrors() @@ -280,7 +280,7 @@ public async Task FromAsyncCancellableFactoryDisposalSuppressesIgnoredCancellati await Assert.That(observer.Completions).IsEqualTo(0); } - /// Verifies that external token cancellation remains a source error while subscribed. + /// Verifies that external token cancellation surfaces as an observer error while the subscription is live. /// A task representing the asynchronous operation. [Test] public async Task FromAsyncCancellableFactoryExternalCancellationForwardsObserverError() @@ -374,7 +374,7 @@ public async Task FromAsyncCancellableFactorySynchronousFailuresForwardObserverE await Assert.That(nullTask.Completed).IsEqualTo(0); } - /// Verifies that already-canceled task factories forward cancellation as observer errors. + /// Verifies that a canceled task and a canceled external token forward cancellation as observer errors. /// A task representing the asynchronous operation. [Test] public async Task FromAsyncCancellableFactoryCanceledTasksForwardObserverErrors() @@ -528,10 +528,7 @@ public async Task RxFactoryAliasesRepeatGenerateUsingIfAndCase() AssertRxFactoryAliasesRejectInvalidArguments(cases); } - /// - /// Verifies an empty range completes immediately without touching the sequencer. There is nothing to emit, so the - /// factory hands back the shared empty signal rather than scheduling a walk over zero values. - /// + /// A sequence of no values completes immediately without scheduling anything. /// A task representing the asynchronous operation. [Test] public async Task SequenceOfNoValuesCompletesWithoutSchedulingAnything() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalGetAwaiterTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalGetAwaiterTests.cs index 7d4d70ce..b8a4cc2c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalGetAwaiterTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalGetAwaiterTests.cs @@ -18,7 +18,7 @@ public class SignalGetAwaiterTests /// Awaiter source values. private static readonly int[] AwaiterSource = [First, Second]; - /// Covers signal awaiter completion, pre-cancellation, and registered cancellation paths. + /// An awaiter returns the last value, and a token cancelled before or after subscription throws. /// A task representing the asynchronous operation. [Test] public async Task GetAwaiterCoversCompletionAndCancellationPaths() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs index 0819b57c..4ef51381 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Advanced.cs @@ -40,10 +40,7 @@ public async Task MapAndKeepSignalsInheritTheCurrentThreadRequirementOfTheirSour await Assert.That(keptPlain.IsRequiredSubscribeOnCurrentThread()).IsFalse(); } - /// - /// A source that keeps notifying after it has completed must not get a second bite at a Keep subscriber. The - /// filter must drop the late value before it even consults the predicate, and drop the late fault entirely. - /// + /// Keep drops the values and faults a source delivers after its completion, without consulting the predicate. /// A task representing the asynchronous operation. [Test] public async Task KeepSignalDropsSourceNotificationsThatArriveAfterItsTerminal() @@ -72,7 +69,7 @@ public async Task KeepSignalDropsSourceNotificationsThatArriveAfterItsTerminal() await Assert.That(predicateCalls).IsEqualTo(0); } - /// Verifies advanced map-indexed and enumerable blend direct paths. + /// Map-indexed signals report their source's thread requirement, and an unbounded blend forwards every value. /// A task representing the asynchronous operation. [Test] public async Task AdvancedSignalsReportThreadRequirementsAndBlendUnboundedSources() @@ -116,7 +113,7 @@ public async Task EnumerableBlendWithAConcurrencyBoundAdmitsSourcesInTurn() var bounded = sources.Blend(1); using var subscription = bounded.Subscribe(blended.Add, static ex => throw ex, () => completions++); - // The bound is one, so the second source must not be subscribed while the first is still running. + // The bound is one, so the second source stays unsubscribed while the first one runs. second.OnNext(UnobservedValue); first.OnNext(One); first.OnCompleted(); @@ -130,7 +127,7 @@ public async Task EnumerableBlendWithAConcurrencyBoundAdmitsSourcesInTurn() _ = Assert.Throws(() => bounded.Subscribe((IObserver)null!)); } - /// Verifies bounded blend drains enumerable sources and suppresses late terminals. + /// A bounded blend drains its sources, forwards only the first fault, and completes on a null enumerator. /// A task representing the asynchronous operation. [Test] public async Task MaxConcurrentBlendCoordinatorDrainsAndSuppressesLateTerminals() @@ -269,7 +266,7 @@ public async Task LatchAndExpireCoordinatorTerminalGuardsAreDeterministic() await Assert.That(expired.Errors[0]).IsTypeOf(); } - /// Verifies take-until terminal guards and distinct set creation branches. + /// Take-until forwards the first terminal from either source, and distinct drops repeats with any comparer. /// A task representing the asynchronous operation. [Test] public async Task TakeUntilAndDistinctTerminalBranchesAreCovered() @@ -346,9 +343,7 @@ public void Schedule(IWorkItem item) "Design", "SST2318:Members should not have identical bodies", Justification = - "The relative and absolute Schedule overloads of this test-double sequencer intentionally behave the " - + "same way; both are required by the ISequencer contract and, as distinct interface overloads, cannot " - + "forward to one another.")] + "Both Schedule overloads are required by the ISequencer contract and cannot forward to one another.")] public void Schedule(IWorkItem item, long dueTimestamp) { item.Execute(); @@ -356,7 +351,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) } } - /// An enumerable that returns a null enumerator for defensive coordinator branches. + /// An enumerable that optionally returns a null enumerator. /// The value type. private sealed class NullEnumeratorEnumerable : IEnumerable> { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs index a51573d0..e29f9b33 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Behavior.cs @@ -114,14 +114,14 @@ public async Task NullGuardsCoverPublicFactoryOperatorAndObserverContracts() await Task.CompletedTask; } - /// Exercises successful operator paths and early-termination branches. + /// Operator chains forward their values, and empty takes and exhausted skips complete without emitting. /// A task representing the asynchronous operation. [Test] [SuppressMessage( "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] public async Task OperatorSurfaceCoversSuccessErrorAndEarlyTerminationBranches() { List values = []; @@ -174,7 +174,7 @@ public async Task OperatorSurfaceCoversSuccessErrorAndEarlyTerminationBranches() await Assert.That(flatMapValues.SequenceEqual(ExpectedFlatMapValues)).IsTrue(); } - /// Exercises error materialization, recovery, resume, and retry branches. + /// Spark, unspark, rescue, resume, and reattempt carry a fault through to the final observer. /// A task representing the asynchronous operation. [Test] public async Task ErrorOperatorsMaterializeRecoverAndResumeDeterministically() @@ -233,7 +233,7 @@ public async Task HigherOrderOperatorsHandleAsyncOrderingRacesSwitchingAndLatest await AssertPairAndForkJoinCompleteWithTheShorterSource(); } - /// Exercises virtual-time operators and aliases. + /// Virtual-time operators emit only once their clock reaches the due time. /// A task representing the asynchronous operation. [Test] public async Task VirtualTimeOperatorsCoverDelayTimeoutSampleTimerAndTimestampAliases() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs index 76e58c6a..22705449 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Delay.cs @@ -59,7 +59,7 @@ public async Task ShiftRetainsTheDrainTimerArmedByAnInlineTick() await Assert.That(observer.Values.SequenceEqual([One])).IsTrue(); } - /// Verifies that dispose waits for in-flight delivery and blocks queued notifications. + /// Disposing during delivery suppresses the queued notifications that follow it. /// A task representing the asynchronous operation. [Test] public async Task ShiftDisposalClaimDuringDeliverySuppressesQueuedNotifications() @@ -121,7 +121,7 @@ public async Task ShiftForwardsDelayedErrorAfterQueuedValues() await Assert.That(sequencer.ScheduledCount).IsEqualTo(0); } - /// Verifies notifications after a terminal one are dropped while the source is stopped. + /// Shift drops the notifications a source delivers after its terminal one. /// A task representing the asynchronous operation. [Test] public async Task ShiftDropsNotificationsAfterTerminalNotification() @@ -205,9 +205,7 @@ private sealed class RecordingSequencer : ISequencer "Design", "SST2318:Members should not have identical bodies", Justification = - "The relative and absolute Schedule overloads of this test-double sequencer intentionally behave the " - + "same way; both are required by the ISequencer contract and, as distinct interface overloads, cannot " - + "forward to one another.")] + "Both Schedule overloads are required by the ISequencer contract and cannot forward to one another.")] public void Schedule(IWorkItem item, long dueTimestamp) => _items.Enqueue(item); /// Advances the scheduler clock without running queued work. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs index 48005413..ebc6c2fd 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Deterministic.cs @@ -66,7 +66,7 @@ public partial class SignalOperatorMixinsTests /// Expected inner-subscribe error message. private static readonly string[] ExpectedInnerSubscribe = ["inner-subscribe"]; - /// Verifies parity alias operators cover remaining lines. + /// Parity aliases forward prepended, fused, and chained values, and an ignoring recovery completes. /// A task representing the asynchronous test. [Test] public async Task ParityAliasOperatorsCoverRemainingLines() @@ -96,13 +96,13 @@ public async Task ParityAliasOperatorsCoverRemainingLines() await Assert.That(ignoredCatchCompleted).IsEqualTo(1); } - /// Verifies range async fast paths and guard clauses cover remaining lines. + /// Sequence fast paths collect their values, and a pre-cancelled token cancels the task sink. /// A task representing the asynchronous test. [Test] [SuppressMessage( "Concurrency", "PSH1313:Call the async overload from an async method", - Justification = "Synchronous CollectArray/CollectList operators are deliberately covered.")] + Justification = "The synchronous CollectArray and CollectList operators are the subject under test.")] public async Task RangeAsyncFastPathsAndNullGuardsCoverRemainingLines() { IEnumerable> blendSources = [Signal.Emit(One), Signal.Emit(Two)]; @@ -124,7 +124,7 @@ public async Task RangeAsyncFastPathsAndNullGuardsCoverRemainingLines() AssertOperatorGuardsRejectNullSourcesAndCallbacks(); } - /// Verifies non-range task terminal sinks use the observer-backed async paths. + /// Count and any terminals await their results over a plain source, and cancel with a pre-cancelled token. /// A task representing the asynchronous test. [Test] public async Task NonRangeTaskTerminalsUseObserverBackedSinks() @@ -144,7 +144,7 @@ await source.AnyAsync(static value => value == One, CancellationToken.None).Conf await Assert.That(source.CountAsync(canceledTerminal.Token).IsCanceled).IsTrue(); } - /// Verifies remaining operator, factory, and observer failure branches are deterministic. + /// Scheduled factories, task signals, observer failures, and flat-map faults behave deterministically. /// A task representing the asynchronous test. [Test] public async Task RemainingOperatorFactoryAndObserverFailureBranchesAreDeterministic() @@ -157,7 +157,7 @@ public async Task RemainingOperatorFactoryAndObserverFailureBranchesAreDetermini await VerifyFlatMapTerminalAndErrorBranches(); } - /// Verifies optimized coordinator and async enumerable branches cover remaining gaps. + /// Async enumerable sources, race, switch, probe, calm, and fork-join coordinators gate their terminals. /// A task representing the asynchronous test. [Test] public async Task OptimizedCoordinatorAndAsyncEnumerableBranchesCoverRemainingGaps() @@ -168,7 +168,7 @@ public async Task OptimizedCoordinatorAndAsyncEnumerableBranchesCoverRemainingGa await VerifyCalmAppendAndForkJoinBranches(); } - /// Verifies range timing, queues, and thread pool cover remaining gaps. + /// Timestamp, time-interval, delay-start, and thread pool work items deliver on their sequencers. /// A task representing the asynchronous test. [Test] public async Task RangeTimingQueuesAndThreadPoolCoverRemainingGaps() @@ -315,7 +315,7 @@ await Assert.That(await Task.FromCanceled(new(true)).HandleCancellation().C "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately verifies eager argument validation thrown synchronously, before the awaitable method returns its task.")] + "The guards under test throw synchronously, before the awaitable method returns its task.")] private static async Task VerifyAliasGuardsAndNullArgumentChecksAsync() { List startWithAlias = []; @@ -347,7 +347,7 @@ private static async Task VerifyAliasGuardsAndNullArgumentChecksAsync() await Assert.That(pendingTask.IsCanceled).IsTrue(); } - /// Verifies immediate signal observer failure branches and the map late-notification branch. + /// A throwing observer propagates out of an immediate signal, and map drops notifications after its terminal. /// A task representing the asynchronous operation. private static async Task VerifyObserverFailureBranchesAndMap() { @@ -397,7 +397,7 @@ private static void VerifyMultiSubscriberOnErrorThrows() _ = Assert.Throws(() => signal.OnError(new InvalidOperationException("many"))); } - /// Verifies FlatMap terminal completion, disposal, and the null-selector and inner-error branches. + /// FlatMap completes after its inners, stops on disposal, and faults on a null selector or failing inner. /// A task representing the asynchronous operation. private static async Task VerifyFlatMapTerminalAndErrorBranches() { @@ -454,7 +454,7 @@ private static async Task VerifyFlatMapTerminalAndErrorBranches() await Assert.That(subscribeErrors.SequenceEqual(ExpectedInnerSubscribe)).IsTrue(); } - /// Verifies async enumerable subscription, shift timing, and expire timeout branches. + /// Async enumerable sources drain to their subscriber, and shift and expire follow the virtual clock. /// A task representing the asynchronous verification. private static async Task VerifyAsyncEnumerableShiftAndExpireAsync() { @@ -507,7 +507,7 @@ private static async Task VerifyAsyncEnumerableShiftAndExpireAsync() await Assert.That(expireError.Errors[0].Message).IsEqualTo("expire-error"); } - /// Verifies the race, synchronized-latest, and switch coordinator branches. + /// Race, synchronized-latest, and switch coordinators gate every losing and stale inner. /// A task representing the asynchronous operation. private static async Task VerifyRaceSyncLatestAndSwitchBranches() { @@ -615,7 +615,7 @@ private static async Task VerifySwitchToForwardsOnlyTheCurrentInner() await Assert.That(switched.Errors[0].Message).IsEqualTo("current-switch"); } - /// Verifies switch outer-error, deferred-completion, and post-terminal gating branches. + /// Switch forwards one outer error, defers completion to its current inner, and gates what follows. /// A task representing the asynchronous operation. private static async Task VerifySwitchTerminalGatingBranches() { @@ -645,10 +645,7 @@ private static async Task VerifySwitchForwardsTheOuterErrorOnceAndGatesWhatFollo await Assert.That(outerErrored.Values.Count).IsEqualTo(0); } - /// - /// Verifies an outer completion waits for the current inner to finish, and that a superseded inner's - /// completion never completes the observer. - /// + /// Switch defers its completion to the current inner and ignores a superseded inner's completion. /// A task representing the asynchronous operation. private static async Task VerifySwitchDefersCompletionUntilTheCurrentInnerFinishes() { @@ -717,7 +714,7 @@ private static async Task VerifySwitchGatesEveryNotificationAfterAnInnerError() await Assert.That(innerErrorCompleted.Completed).IsEqualTo(0); } - /// Verifies the probe operator error, disposal, and completion branches. + /// Probe forwards a fault, stops after disposal, and completes without a sampled value. /// A task representing the asynchronous operation. private static async Task VerifyProbeBranches() { @@ -762,7 +759,7 @@ private static async Task VerifyProbeBranches() await Assert.That(currentThreadProbe.Completed).IsEqualTo(1); } - /// Verifies the calm debounce, append observer failure, and fork-join completion branches. + /// Calm emits only the quiet value, append surfaces observer faults, and fork-join waits for both sides. /// A task representing the asynchronous operation. private static async Task VerifyCalmAppendAndForkJoinBranches() { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs index a7d3f97d..9668f59f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.PostTerminalGuards.cs @@ -46,7 +46,7 @@ public async Task BlendStopsForwardingInnerValuesOnceTheSubscriptionIsDisposed() await Assert.That(inner.HasObservers).IsFalse(); } - /// Verifies Shift drops values a source delivers after it has already completed. + /// Verifies Shift drops the values a source delivers after its completion. /// A task representing the asynchronous operation. [Test] public async Task ShiftDropsValuesDeliveredAfterTheSourceCompletes() @@ -77,7 +77,7 @@ public async Task CalmOnTheCurrentThreadSequencerEmitsTheQuietValue() await Assert.That(values.SequenceEqual([One, Two])).IsTrue(); } - /// Verifies a stale Calm timer tick does not re-emit the value it already delivered. + /// Verifies a stale Calm timer tick does not re-emit the value it delivered. /// A task representing the asynchronous operation. [Test] public async Task CalmIgnoresAStaleTimerTickAndPostTerminalNotifications() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs index be543818..98d01a84 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.Scheduling.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Tests; /// Tests scheduled delivery and virtual-time operator behavior. public partial class SignalOperatorMixinsTests { - /// Verifies the timestamp operator immediate and clock-backed branches. + /// Timestamp stamps every value from its immediate or clock-backed sequencer and rejects null observers. /// A task representing the asynchronous operation. private static async Task VerifyTimestampBranches() { @@ -56,7 +56,7 @@ private static async Task VerifyTimestampBranches() immediateTimestampSignal.Subscribe((Action>)null!, static _ => { }, static () => { })); } - /// Verifies the time-interval operator immediate and clock-backed branches. + /// Time-interval reports a zero gap for values its sequencer delivers without advancing the clock. /// A task representing the asynchronous operation. private static async Task VerifyTimeIntervalBranches() { @@ -116,7 +116,7 @@ private static async Task VerifyTimeIntervalBranches() immediateIntervalSignal.Subscribe((Action>)null!, static _ => { }, static () => { })); } - /// Verifies delay-start signal branches, the sequencer work item, and queue guard clauses. + /// Verifies delay-start delivery, the sequencer work item, and the queue guard clauses. /// A task representing the asynchronous operation. private static async Task VerifyDelayStartAndWorkItemBranches() { @@ -173,8 +173,6 @@ private static async Task VerifyDelayStartAndWorkItemBranches() /// A task representing the asynchronous verification. private static async Task VerifySequencerWorkItemDisposalBranches() { - // Invoke then dispose: the published disposable is released by Dispose exactly once, - // and a redundant second Dispose is a no-op. var invokeThenDisposeReleased = 0; SequencerWorkItem invokeThenDispose = new(Sequencer.Immediate, One, (_, _) => new ActionDisposable(() => Interlocked.Increment(ref invokeThenDisposeReleased))); @@ -198,11 +196,10 @@ private static async Task VerifySequencerWorkItemDisposalBranches() await VerifySequencerWorkItemDisposeRaceInvariant(); } - /// Verifies both compare-exchange outcomes of SequencerWorkItem.Publish. + /// Publish stores its disposable into an empty slot and releases it when the slot is claimed. /// A task representing the asynchronous verification. private static async Task VerifySequencerWorkItemPublishBranches() { - // Publish wins the empty slot: the disposable is stored and left alive for Dispose. var stored = 0; ActionDisposable storedDisposable = new(() => Interlocked.Increment(ref stored)); IDisposable? winSlot = null; @@ -210,7 +207,7 @@ private static async Task VerifySequencerWorkItemPublishBranches() await Assert.That(ReferenceEquals(winSlot, storedDisposable)).IsTrue(); await Assert.That(stored).IsEqualTo(0); - // Publish loses to disposal (slot already claimed): the disposable is released immediately. + // A non-null slot means disposal claimed it first, so Publish releases the disposable. var loserDisposed = 0; ActionDisposable loser = new(() => Interlocked.Increment(ref loserDisposed)); IDisposable? loseSlot = EmptyDisposable.Instance; @@ -235,7 +232,7 @@ private static async Task VerifySequencerWorkItemDisposeRaceInvariant() await Assert.That(disposed).IsEqualTo(1); } - /// Verifies the thread pool absolute scheduling and scheduled work item disposal branches. + /// Verifies absolute thread pool scheduling and the disposal of a scheduled work item. /// A task representing the asynchronous verification. private static async Task VerifyThreadPoolWorkItemBranchesAsync() { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs index 680018c3..ca196fa6 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs @@ -54,25 +54,25 @@ public partial class SignalOperatorMixinsTests /// The expected one-through-four sequence emitted by the four-element source. private static readonly int[] ExpectedOneToFour = [One, Two, Three, Four]; - /// The expected single null produced by the default-if-empty branch. + /// The expected single null from default-if-empty. private static readonly int?[] ExpectedSingleNull = [null]; - /// The expected one-and-two prefix retained by take-while and distinct branches. + /// The expected one-and-two prefix retained by take-while and distinct. private static readonly int[] ExpectedOneTwo = [One, Two]; - /// The expected three-and-four suffix retained by skip-while and delay branches. + /// The expected three-and-four suffix retained by skip-while and delay. private static readonly int[] ExpectedThreeFour = [Three, Four]; - /// The expected single nine produced by the fork-join sum branch. + /// The expected single nine produced by the fork-join sum. private static readonly int[] ExpectedSingleNine = [Nine]; - /// The expected two-through-four prefix produced by the single prepend branch. + /// The expected two-through-four sequence produced by a single prepend. private static readonly int[] ExpectedTwoToFour = [Two, Three, Four]; - /// The expected message from the single delayed-error branch. + /// The expected message from a delayed error. private static readonly string[] ExpectedDelayErrors = ["delay-error"]; - /// The expected error type name from the expire-timeout branch. + /// The expected error type name from an expire timeout. private static readonly string[] ExpectedTimeoutErrors = [nameof(TimeoutException)]; /// The expected single true value emitted by the true signal. @@ -81,34 +81,34 @@ public partial class SignalOperatorMixinsTests /// The expected single false value emitted by the false signal. private static readonly bool[] ExpectedFalseValues = [false]; - /// The expected single seven produced by the scheduled branch. + /// The expected single seven produced by scheduled work. private static readonly int[] ExpectedSingleSeven = [Seven]; - /// The expected two-and-three sequence produced by the observer prepend branch. + /// The expected two-and-three sequence produced by an observer prepend. private static readonly int[] ExpectedTwoThree = [Two, Three]; - /// The expected one-through-three sequence produced by the prepend/append branches. + /// The expected one-through-three sequence produced by prepend and append. private static readonly int[] ExpectedOneToThree = [One, Two, Three]; - /// The expected three-and-five sequence produced by the combine-latest branch. + /// The expected three-and-five sequence produced by combine-latest. private static readonly int[] ExpectedThreeFive = [Three, Five]; - /// The expected single two produced by the switch branch. + /// The expected single two produced by switching inners. private static readonly int[] ExpectedSingleTwo = [Two]; - /// The expected single five produced by the typed catch branch. + /// The expected single five produced by a typed recovery. private static readonly int[] ExpectedSingleFive = [Five]; - /// The expected message from the keep-predicate fault branch. + /// The expected message from a faulting keep predicate. private static readonly string[] ExpectedKeepErrors = ["keep-predicate"]; - /// The expected message from the all-predicate fault branch. + /// The expected message from a faulting all predicate. private static readonly string[] ExpectedAllErrors = ["all-predicate"]; - /// The expected messages from the recover handler-fault and unmatched branches. + /// The expected messages from a faulting recover handler and an unmatched error. private static readonly string[] ExpectedCatchErrors = ["handler-threw", "not-matched"]; - /// Covers parity operator overloads, aliases, and argument guards that are not hit by scenario tests. + /// Parity operator aliases forward their values, report side effects, and reject null arguments. /// A task representing the asynchronous operation. [Test] public async Task ParityOperatorAliasesAndGuardsCoverRemainingBranches() @@ -160,7 +160,7 @@ public async Task ParityOperatorAliasesAndGuardsCoverRemainingBranches() AssertAggregateAndTimingGuards(source); } - /// Covers prepend, observe-on, default-if-empty, and time-shift alias edge branches. + /// Prepend, observe-on, default-if-empty, and time-shift aliases return their shortcut results. /// A task representing the asynchronous operation. [Test] public async Task AliasAndTimeShiftBranchesCoverRemainingEdges() @@ -192,7 +192,7 @@ public async Task AliasAndTimeShiftBranchesCoverRemainingEdges() await Assert.That(timeoutErrors.SequenceEqual(ExpectedTimeoutErrors)).IsTrue(); } - /// Covers deterministic shortcut branches in primitive-vocabulary operator wrappers. + /// Primitive operator wrappers shortcut cancelable sources, empty takes, and pending tasks. /// A task representing the asynchronous operation. [Test] public async Task PrimitiveShortcutBranchesCoverCancelableFallbackAndPendingTaskPaths() @@ -242,7 +242,7 @@ public async Task PrimitiveShortcutBranchesCoverCancelableFallbackAndPendingTask await Assert.That(stringSwitch.SequenceEqual(ExpectedSingleValue)).IsTrue(); } - /// Covers immutable boolean and rx-void return-signal inline subscription branches. + /// Immutable boolean and rx-void return signals deliver their value inline and complete. /// A task representing the asynchronous operation. [Test] public async Task ImmutableReturnSignalsCoverInlineBranches() @@ -270,7 +270,7 @@ await Assert.That(((IRequireCurrentThread)voidSignal).IsRequiredSubscrib await Assert.That(inlineCompleted).IsEqualTo(Three); } - /// Covers minimal virtual-clock scheduling guards and dispatch branches. + /// The minimal virtual clock rejects invalid advances and null actions, and dispatches due work. /// A task representing the asynchronous operation. [Test] public async Task MinimalVirtualClockSchedulingCoversGuardsAndDispatch() @@ -296,7 +296,7 @@ public async Task MinimalVirtualClockSchedulingCoversGuardsAndDispatch() await Assert.That(scheduled.SequenceEqual(ExpectedSingleSeven)).IsTrue(); } - /// Covers observer-based inline operator paths and private observer error cleanup paths. + /// Inline prepend and append operators reject null observers and propagate observer failures. /// A task representing the asynchronous operation. [Test] public async Task InlineOperatorObserverAndErrorCleanupPathsCoverRemainingBranches() @@ -339,7 +339,7 @@ public async Task InlineOperatorObserverAndErrorCleanupPathsCoverRemainingBranch await Assert.That(delegateErrors).IsEqualTo(1); } - /// Covers coordinator paths where later sources complete or error after another source has won. + /// Race, combine, and switch coordinators ignore sources that terminate after another has won. /// A task representing the asynchronous operation. [Test] public async Task HigherOrderCoordinatorRaceCombineSwitchPathsCoverLateBranches() @@ -395,7 +395,7 @@ public async Task HigherOrderCoordinatorRaceCombineSwitchPathsCoverLateBranches( await Assert.That(switched.Completed).IsEqualTo(1); } - /// Covers observer exception paths and typed catch/finally branches with deterministic synchronous sources. + /// Predicate faults, typed recovery, and cleanup handlers surface their errors once. /// A task representing the asynchronous operation. [Test] public async Task ObserverExceptionCatchFinallyAndTerminalPredicateBranchesCoverRemainders() @@ -451,7 +451,7 @@ public async Task ObserverExceptionCatchFinallyAndTerminalPredicateBranchesCover await Assert.That(finallyCalls).IsEqualTo(1); } - /// Covers fused prepend/default-if-empty/append and empty Prepend helpers. + /// Fused prepend, default-if-empty, and append preserve value ordering and the single terminal. /// A task representing the asynchronous operation. [Test] public async Task PrependAppendDefaultIfEmptyFusionPreservesOrderingAndTerminals() @@ -508,7 +508,7 @@ public async Task IndexedMappingTaskChainAndTaskSignalAliasesCoverCompatibilityP _ = Assert.Throws(static () => ((IObservable>)null!).Chain()); } - /// Verifies direct task-chain sequencing without the map adapter. + /// Task chaining emits pending task results in source order. /// A task representing the asynchronous operation. [Test] public async Task TaskChainDirectSignalKeepsPendingTasksInSourceOrder() @@ -533,7 +533,7 @@ public async Task TaskChainDirectSignalKeepsPendingTasksInSourceOrder() await Assert.That(chained.Completions).IsEqualTo(One); } - /// Verifies direct task-chain terminal and disposal paths. + /// Task chaining forwards a source fault, a null task, and a task fault, and drops notifications after disposal. /// A task representing the asynchronous operation. [Test] public async Task TaskChainDirectSignalHandlesErrorsAndDisposal() @@ -585,7 +585,7 @@ public async Task TaskChainDirectSignalHandlesErrorsAndDisposal() await Assert.That(disposed.Completed).IsEqualTo(0); } - /// Covers default-if-empty behavior over hot sources for empty, non-empty, error, and observer-guard branches. + /// Default-if-empty substitutes its fallback only for an empty source and rejects a null observer. /// A task representing the asynchronous operation. [Test] public async Task DefaultIfEmptyCoversHotSourceEmptyNonEmptyErrorAndObserverGuard() @@ -621,7 +621,7 @@ public async Task DefaultIfEmptyCoversHotSourceEmptyNonEmptyErrorAndObserverGuar "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task TelemetryBurstBuffersTerminalCountsAndSubscriberChurnAreDeterministic() { @@ -743,7 +743,7 @@ private static async Task AssertTaskSignalAliasesForwardResultsCancellationAndFa await Assert.That(faultedTaskSignal.Errors[0]).IsSameReferenceAs(taskError); } - /// Covers the synchronous collect-list and collect-array operator branches. + /// Asserts collect-list and collect-array emit the entire source as a single value. /// A four-element integer source. /// A collect operator produced an unexpected sequence. private static void VerifyCollectOperators(IObservable source) diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Aggregates.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Aggregates.cs index 6dea0e6c..fa93a775 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Aggregates.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Aggregates.cs @@ -98,13 +98,13 @@ public partial class SignalOperatorParityMixinsTests /// Expected result-selector FlatMap values. private static readonly int[] ResultFlatMapExpected = [First + FirstInner, Second + FirstInner]; - /// Covers count, long-count, distinct fast count, and any helper branches. + /// The count, long-count, distinct-count, and any helpers report their aggregate. /// A task representing the asynchronous operation. [SuppressMessage( "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task AggregateHelpersCoverPredicateDistinctAndAnyPaths() { @@ -160,13 +160,13 @@ public async Task AggregateHelpersCoverPredicateDistinctAndAnyPaths() await Assert.That(rangeContainsFalse.SequenceEqual(FalseExpected)).IsTrue(); } - /// Covers optimized aggregate observer error paths. + /// The optimized aggregate observers forward a source error unchanged. /// A task representing the asynchronous operation. [SuppressMessage( "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task AggregateHelpersForwardSourceErrors() { @@ -186,7 +186,7 @@ public async Task AggregateHelpersForwardSourceErrors() await Assert.That(observed[DistinctErrorIndex]).IsSameReferenceAs(distinctError); } - /// Covers predicate exceptions for aggregate boolean terminals. + /// The boolean aggregate terminals forward an exception thrown by the predicate. /// A task representing the asynchronous operation. [Test] public async Task AggregateBooleanTerminalsForwardPredicateErrors() @@ -197,7 +197,7 @@ public async Task AggregateBooleanTerminalsForwardPredicateErrors() await Assert.That(observed[0]).IsSameReferenceAs(allError); } - /// Covers FlatMap queuing while an inner signal is active. + /// FlatMap queues a later inner signal until the active inner signal completes. /// A task representing the asynchronous operation. [Test] public async Task FlatMapQueuesInnerSignalsUntilActiveInnerCompletes() @@ -220,7 +220,7 @@ public async Task FlatMapQueuesInnerSignalsUntilActiveInnerCompletes() await Assert.That(completed).IsEqualTo(1); } - /// Covers the FlatMap overload with an outer and inner result selector. + /// The FlatMap result-selector overload projects each outer value with its inner value. /// A task representing the asynchronous operation. [Test] public async Task FlatMapResultSelectorProjectsOuterAndInnerValues() @@ -232,7 +232,7 @@ public async Task FlatMapResultSelectorProjectsOuterAndInnerValues() await Assert.That(values.SequenceEqual(ResultFlatMapExpected)).IsTrue(); } - /// Covers FlatMap selector, inner, and outer error forwarding. + /// FlatMap forwards a selector, inner, and outer error to the observer. /// A task representing the asynchronous operation. [Test] public async Task FlatMapForwardsSelectorInnerAndOuterErrors() @@ -320,7 +320,7 @@ public async Task RangeTerminalTasksFaultWhenThePredicateThrows() await Assert.That(countError!).IsSameReferenceAs(expected); } - /// The predicate terminals cancel up front when handed an already-cancelled token. + /// The predicate terminals cancel up front when handed a pre-cancelled token. /// A task representing the asynchronous operation. [Test] public async Task PredicateTerminalTasksCancelUpFrontOnAnAlreadyCancelledToken() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Cancellation.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Cancellation.cs index 89ae8bba..76a044c7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Cancellation.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.Cancellation.cs @@ -64,7 +64,7 @@ public async Task ToTaskOnPendingTaskWithPreCanceledTokenIsCanceled() await Assert.That(task.IsCanceled).IsTrue(); } - /// Verifies the issue #108 scenario: FirstAsync().ToTask(token) cancels when the token fires before a value arrives. + /// Verifies FirstAsync().ToTask(token) cancels when the token fires before a value arrives. /// A task representing the asynchronous operation. [Test] public async Task FirstAsyncToTaskCancelsWhenTokenFires() @@ -80,7 +80,7 @@ public async Task FirstAsyncToTaskCancelsWhenTokenFires() await Assert.That(() => pending).Throws(); } - /// Verifies the issue #108 scenario: FirstAsync().ToTask(token) yields the first matching value when it arrives before cancellation. + /// Verifies FirstAsync().ToTask(token) yields the first matching value when it arrives before cancellation. /// A task representing the asynchronous operation. [Test] public async Task FirstAsyncToTaskYieldsFirstMatchingValue() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.RangeFastPaths.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.RangeFastPaths.cs index 5a16ee79..8d3d22b6 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.RangeFastPaths.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.RangeFastPaths.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the range-backed fast paths of the aggregate operators: a sequence built from -/// is answered from its bounds instead of by enumerating it, and a -/// predicate that throws while the bounds are being walked is still reported as an error to the observer. -/// +/// Verifies the range-backed fast paths of the aggregate operators. public partial class SignalOperatorParityMixinsTests { /// Verifies the aggregate operators answer a range source directly from its bounds. @@ -20,7 +16,7 @@ public partial class SignalOperatorParityMixinsTests "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task RangeBackedAggregatesAreAnsweredFromTheRangeBounds() { @@ -47,7 +43,7 @@ public async Task RangeBackedAggregatesAreAnsweredFromTheRangeBounds() "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task RangeBackedAggregatesReportAPredicateFailureAsAnError() { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.cs index d020e9df..105c32c3 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorParityMixinsTests.cs @@ -27,7 +27,7 @@ public partial class SignalOperatorParityMixinsTests /// The long constant two. private const long TwoLong = 2L; - /// The expected one-and-two prefix retained by distinct branches. + /// The expected one-and-two prefix from the distinct operators. private static readonly int[] ExpectedOneTwo = [One, Two]; /// The expected single two produced by the distinct count alias. @@ -39,13 +39,13 @@ public partial class SignalOperatorParityMixinsTests /// The expected single true value emitted by a true signal. private static readonly bool[] ExpectedTrueValues = [true]; - /// Covers operator Subscribe(null) and current-thread propagation for internal optimized signal classes. + /// The optimized operator signals reject a null observer and do not require the current thread. /// A task representing the asynchronous operation. [SuppressMessage( "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task InternalOptimizedOperatorSignalsValidateObserversAndThreadRequirements() { @@ -98,13 +98,13 @@ public async Task InternalOptimizedOperatorSignalsValidateObserversAndThreadRequ } } - /// Covers optimized aggregate helper paths with custom comparers, selector exceptions, and range-backed count aliases. + /// The optimized aggregates honor custom comparers, surface selector faults, and count range sources. /// A task representing the asynchronous operation. [SuppressMessage( "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task AggregateOptimizedSignalsCoverComparerAndExceptionPaths() { @@ -145,13 +145,13 @@ public async Task AggregateOptimizedSignalsCoverComparerAndExceptionPaths() await Assert.That(containsRange.Values.SequenceEqual(ExpectedTrueValues)).IsTrue(); } - /// Covers terminal observers that must ignore protocol violations after their first terminal signal. + /// Terminal observers ignore notifications after their first terminal signal and forward predicate failures. /// A task representing the asynchronous operation. [SuppressMessage( "Concurrency", "PSH1313:Call the async overload from an async method", Justification = - "This test deliberately exercises the synchronous IObservable operator overloads, not their awaitable terminal counterparts.")] + "The synchronous IObservable operator overloads are the subject under test.")] [Test] public async Task TerminalObserversIgnoreLateSignalsAndForwardPredicateFailures() { @@ -194,7 +194,7 @@ public async Task TerminalObserversIgnoreLateSignalsAndForwardPredicateFailures( await Assert.That(values).Contains(true); } - /// Verifies scheduler-based ToObservable conversion and task conversion aliases. + /// The scheduler and task ToObservable aliases emit their values and honor cancellation. /// A task representing the asynchronous operation. [Test] public async Task ToObservableSchedulerAndTaskAliasesEmitAndHonorCancellation() @@ -252,7 +252,7 @@ public async Task ToObservableSchedulerAndTaskAliasesEmitAndHonorCancellation() _ = Assert.Throws(static () => ((Task)null!).ToObservable()); } - /// Covers deterministic shortcut branches in System.Reactive-name parity operators. + /// The System.Reactive-named parity operators apply their default arguments and range fast paths. /// A task representing the asynchronous operation. [Test] public async Task ParityShortcutBranchesCoverDefaultsAndRangeFastPaths() @@ -311,7 +311,7 @@ public async Task ParityShortcutBranchesCoverDefaultsAndRangeFastPaths() await Assert.That(fusedRanges.SequenceEqual(expectedLatest)).IsTrue(); } - /// Creates a source that keeps signalling values and a completion after it has already errored. + /// Creates a source that signals a value and a completion after it has errored. /// The misbehaving source. private static ScriptedObservable CreateSourceThatKeepsSignallingAfterItsError() => new(static observer => @@ -322,7 +322,7 @@ private static ScriptedObservable CreateSourceThatKeepsSignallingAfterItsEr observer.OnCompleted(); }); - /// Creates a source that keeps signalling values and an error after it has already completed. + /// Creates a source that signals an error and a value after it has completed. /// The misbehaving source. private static ScriptedObservable CreateSourceThatKeepsSignallingAfterItsCompletion() => new(static observer => diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs index 4a6c542d..524e4a4e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs @@ -24,7 +24,7 @@ public class SignalRecoverTests /// Expected values produced by the catch params overload. private static readonly int[] CatchRecoveryExpected = [First, Second]; - /// Covers catch sequence recovery, final error, empty completion, null source, and enumerator failure branches. + /// A recover sequence falls through to the first source that succeeds, and reports a null or failing source. /// A task representing the asynchronous operation. [Test] public async Task CatchParamsFactoryCoversRecoveryAndFailureBranches() @@ -123,7 +123,6 @@ public async Task DisposedRecoverSequenceIgnoresALateUpstreamError() upstream!.OnError(new InvalidOperationException(FirstMessage)); - // The sequence was torn down, so the fallback source must never be subscribed and nothing may reach downstream. await Assert.That(witness.Values.Count).IsEqualTo(0); await Assert.That(witness.Errors.Count).IsEqualTo(0); await Assert.That(witness.Completed).IsEqualTo(0); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.Aliases.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.Aliases.cs index 4b7a741c..bad2caa7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.Aliases.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.Aliases.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Primitives.Tests; /// Tests Rx-style alias factories and empty-source shortcuts. public partial class SignalTests { - /// Verifies Rx factory aliases cover scheduled, empty, timeout, and switch range branches. + /// Rx-named aliases produce scheduled, empty, and timeout signals, and switch flattens range and non-range sources. /// A task representing the asynchronous operation. [Test] public async Task RxFactoryAliasesCoverScheduledAndSwitchRangeBranches() @@ -43,7 +43,7 @@ public async Task RxFactoryAliasesCoverScheduledAndSwitchRangeBranches() await Assert.That(nonRangeSwitch.SequenceEqual([Five])).IsTrue(); } - /// Verifies parity aliases cover task terminal shortcut branches. + /// Task conversion forwards a completed result, a cancellation, a fault, and a pending result. /// A task representing the asynchronous operation. [Test] public async Task ParityAliasesCoverTaskObservableBranches() @@ -69,7 +69,7 @@ public async Task ParityAliasesCoverTaskObservableBranches() await Assert.That(pendingWitness.Values.SequenceEqual([Two])).IsTrue(); } - /// Verifies parity operators cover remaining public range and alias branches. + /// Range-backed aliases emit their values through default, unique, timestamp, concat, merge, and pair operators. /// A task representing the asynchronous operation. [Test] public async Task ParityOperatorsCoverRangeAndAliasBranches() @@ -115,7 +115,7 @@ public async Task ParityOperatorsCoverRangeAndAliasBranches() await Assert.That(latestWithNonRangeRight.SequenceEqual([Two + Three, Two + Four])).IsTrue(); } - /// Verifies direct from-async subscriptions cover constructor and synchronous completion paths. + /// A from-async subscription emits and completes synchronously, even when its factory disposes it. /// A task representing the asynchronous operation. [Test] public async Task FromAsyncSubscriptionsCoverConstructorAndSynchronousCompletionPaths() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs index c3a13602..6fcf32a8 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs @@ -89,7 +89,7 @@ public partial class SignalTests /// Expected pair of RxVoid notifications. private static readonly RxVoid[] DoubleRxVoid = [RxVoid.Default, RxVoid.Default]; - /// Called when [next]. + /// A subscriber receives each value until its subscription is disposed. /// A task representing the asynchronous operation. [Test] public async Task OnNext() @@ -106,7 +106,7 @@ public async Task OnNext() await Assert.That(value).IsEqualTo(PairCount); } - /// Called when [next disposed]. + /// A disposed signal throws when a value is emitted. [Test] public void OnNextDisposed() { @@ -115,7 +115,7 @@ public void OnNextDisposed() _ = Assert.Throws(() => subject.OnNext(1)); } - /// Called when [next disposed subscriber]. + /// A disposed subscriber receives no further values. /// A task representing the asynchronous operation. [Test] public async Task OnNextDisposedSubscriber() @@ -127,7 +127,7 @@ public async Task OnNextDisposedSubscriber() await Assert.That(value).IsEqualTo(0); } - /// Called when [completed]. + /// A subscriber is notified when the signal completes. /// A task representing the asynchronous operation. [Test] public async Task OnCompleted() @@ -141,7 +141,7 @@ public async Task OnCompleted() await Assert.That(completed).IsTrue(); } - /// Called when [completed no op]. + /// Completing a signal whose subscriber handles values only raises no error. [Test] public void OnCompleted_NoErrors() { @@ -150,7 +150,7 @@ public void OnCompleted_NoErrors() subject.OnCompleted(); } - /// Called when [completed once]. + /// Only the first completion is forwarded. /// A task representing the asynchronous operation. [Test] public async Task OnCompletedOnce() @@ -166,7 +166,7 @@ public async Task OnCompletedOnce() await Assert.That(completed).IsEqualTo(1); } - /// Called when [completed disposed]. + /// A disposed signal throws when it is completed. [Test] public void OnCompletedDisposed() { @@ -175,7 +175,7 @@ public void OnCompletedDisposed() _ = Assert.Throws(subject.OnCompleted); } - /// Called when [completed disposed subscriber]. + /// A disposed subscriber is not notified of completion. /// A task representing the asynchronous operation. [Test] public async Task OnCompletedDisposedSubscriber() @@ -189,7 +189,7 @@ public async Task OnCompletedDisposedSubscriber() await Assert.That(completed).IsFalse(); } - /// Called when [error]. + /// An error reaches the subscriber's error handler. /// A task representing the asynchronous operation. [Test] public async Task OnError() @@ -203,7 +203,7 @@ public async Task OnError() await Assert.That(error).IsTrue(); } - /// Called when [error once]. + /// Only the first error is forwarded. /// A task representing the asynchronous operation. [Test] public async Task OnErrorOnce() @@ -219,7 +219,7 @@ public async Task OnErrorOnce() await Assert.That(errors).IsEqualTo(1); } - /// Called when [error disposed]. + /// A disposed signal throws when an error is raised. [Test] public void OnErrorDisposed() { @@ -228,7 +228,7 @@ public void OnErrorDisposed() _ = Assert.Throws(() => subject.OnError(new InvalidOperationException())); } - /// Called when [error disposed subscriber]. + /// A disposed subscriber is not notified of an error. /// A task representing the asynchronous operation. [Test] public async Task OnErrorDisposedSubscriber() @@ -266,7 +266,7 @@ public async Task SingleObserverSubscriptionReceivesLifecycleAndDetaches() await Assert.That(faulted.HasObservers).IsFalse(); } - /// Called when [error rethrows by default]. + /// An error raised with no error handler subscribed rethrows at the call site. [Test] public void OnErrorRethrowsByDefault() { @@ -275,17 +275,17 @@ public void OnErrorRethrowsByDefault() _ = Assert.Throws(() => subject.OnError(new ArgumentException("subject error"))); } - /// Called when [error null throws]. + /// A null error is rejected. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void OnErrorNullThrows() => Assert.Throws(static () => new Signal().OnError(null!)); - /// Subscribes the null throws. + /// A null observer is rejected. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void SubscribeNullThrows() => Assert.Throws(static () => new Signal().Subscribe(null!)); - /// Subscribes the disposed throws. + /// Subscribing to a disposed signal throws. [Test] public void SubscribeDisposedThrows() { @@ -294,7 +294,7 @@ public void SubscribeDisposedThrows() _ = Assert.Throws(() => subject.Subscribe(static _ => { })); } - /// Subscribes the on completed. + /// A subscriber that arrives after completion is completed at once. /// A task representing the asynchronous operation. [Test] public async Task SubscribeOnCompleted() @@ -308,7 +308,7 @@ public async Task SubscribeOnCompleted() await Assert.That(completed).IsTrue(); } - /// Subscribes the on error. + /// A subscriber that arrives after a fault receives that fault. /// A task representing the asynchronous operation. [Test] public async Task SubscribeOnError() @@ -322,7 +322,7 @@ public async Task SubscribeOnError() await Assert.That(error).IsTrue(); } - /// Subscribes action observers, converts to multi-observer dispatch, and removes each observer independently. + /// Each action subscriber stops receiving values when its own subscription is disposed. /// A task representing the asynchronous operation. [Test] public async Task SubscribeActionObservers_DisposeIndependently() @@ -348,7 +348,7 @@ public async Task SubscribeActionObservers_DisposeIndependently() await Assert.That(subject.HasObservers).IsFalse(); } - /// Subjects the where. + /// A filtered signal forwards only the values its predicate accepts. /// A task representing the asynchronous operation. [Test] [RequiresUnreferencedCode("Tests the action-based Subscribe overload that carries trimming annotations.")] @@ -364,7 +364,7 @@ public async Task SubjectWhere() await Assert.That(values).IsEquivalentTo([ValueTwo]); } - /// Subjects the select. + /// A mapped signal forwards the projected value. /// A task representing the asynchronous operation. [Test] [RequiresUnreferencedCode("Tests the action-based Subscribe overload that carries trimming annotations.")] @@ -378,7 +378,7 @@ public async Task SubjectSelect() await Assert.That(values).IsEquivalentTo([ValueFour]); } - /// Subjects the buffer. + /// A count-buffered signal emits each full batch of values. /// A task representing the asynchronous operation. [Test] public async Task SubjectBuffer() @@ -398,7 +398,7 @@ public async Task SubjectBuffer() subject.Dispose(); } - /// Subjects the buffer skip2. + /// A skipping buffer emits only the batches its window covers. /// A task representing the asynchronous operation. [Test] public async Task SubjectBufferTake2Skip2() @@ -421,7 +421,7 @@ public async Task SubjectBufferTake2Skip2() subject.Dispose(); } - /// Subjects the rx void. + /// A unit-valued signal forwards each notification. /// A task representing the asynchronous operation. [Test] public async Task SubjectRxVoid() @@ -436,7 +436,7 @@ public async Task SubjectRxVoid() subject.Dispose(); } - /// Verifies immediate core signals, range, zip, repeat, and observer failures cover remainders. + /// Immediate core signals, ranges, zips, and repeats forward their sequences and surface observer failures. /// A task representing the asynchronous operation. [Test] public async Task ImmediateCoreSignalsRangeZipRepeatAndObserverFailuresCoverRemainders() @@ -468,7 +468,7 @@ await Assert.That(((IRequireCurrentThread)Signal.EmitRxVoid()).IsRequire AssertObserverFailuresPropagateOutOfSubscribe(); } - /// Covers signal subject subscriber churn, late subscriptions, disposal, and terminal no-op branches. + /// A signal keeps its subscribers through churn, replays terminals to late subscribers, and throws once disposed. /// A task representing the asynchronous operation. [Test] public async Task SubjectsCoverMultipleSubscriberChurnLateTerminalsAndDisposalBranches() @@ -523,7 +523,7 @@ public async Task SubjectsCoverMultipleSubscriberChurnLateTerminalsAndDisposalBr _ = Assert.Throws(() => disposedSubject.OnNext(1)); } - /// Disposal during dispatch preserves the captured observer delivery and reports disposal to the caller. + /// Disposing the signal from inside a dispatch delivers the value to its peers and reports the disposal. /// A task representing the asynchronous operation. [Test] public async Task OnNextReportsDisposalWhenASubscriberDisposesTheSignalMidDispatch() @@ -531,8 +531,7 @@ public async Task OnNextReportsDisposalWhenASubscriberDisposesTheSignalMidDispat Signal signal = new(); RecordingWitness survivor = new(); - // Two subscribers put the signal on the multi-subscriber dispatch path rather than the single-observer - // fast path, which returns before the disposal is ever noticed. + // Two subscribers force the multi-subscriber dispatch path, the only one that notices the disposal. _ = signal.Subscribe(Witness.Create(_ => signal.Dispose())); _ = signal.Subscribe(survivor); @@ -542,10 +541,7 @@ public async Task OnNextReportsDisposalWhenASubscriberDisposesTheSignalMidDispat await Assert.That(survivor.Values.SequenceEqual([One])).IsTrue(); } - /// - /// The finalizer path releases unmanaged resources only. It must leave the signal usable: a signal that - /// tore down its observers here would silently drop subscribers whenever a finalizer ran. - /// + /// The finalizer disposal path releases unmanaged resources only and leaves the signal usable. /// A task representing the asynchronous operation. [Test] public async Task UnmanagedOnlyDisposalLeavesTheSignalUsable() @@ -561,11 +557,7 @@ public async Task UnmanagedOnlyDisposalLeavesTheSignalUsable() await Assert.That(observer.Values.SequenceEqual([One])).IsTrue(); } - /// - /// Faults rethrow at the call site only to reach action subscribers, which have nowhere else to surface an - /// error. With observer subscribers alone — and enough of them to leave the single-subscriber fast path — - /// the error is delivered and OnError returns quietly. - /// + /// An error is delivered and OnError returns quietly when only observers are subscribed. /// A task representing the asynchronous operation. [Test] public async Task OnErrorDoesNotRethrowWhenOnlyObserversAreSubscribed() @@ -584,10 +576,7 @@ public async Task OnErrorDoesNotRethrowWhenOnlyObserversAreSubscribed() await Assert.That(second.Errors[0]).IsSameReferenceAs(error); } - /// - /// The subscription array reuses the slot a departed subscriber vacated instead of growing. The newcomer - /// must be wired up properly in that recycled slot, and the departed subscriber must stay silent. - /// + /// A newly added subscriber receives values from the vacated slot, and the departed subscriber stays silent. /// A task representing the asynchronous operation. [Test] public async Task ASubscriberAddedAfterAnotherLeavesReusesTheVacatedSlot() @@ -609,11 +598,7 @@ public async Task ASubscriberAddedAfterAnotherLeavesReusesTheVacatedSlot() await Assert.That(arrived.Values.SequenceEqual([One])).IsTrue(); } - /// - /// Small integers come from a shared cache rather than a fresh allocation. Both subscription surfaces of a - /// cached signal must still replay the value they were built for, and values outside the cache must still - /// work. - /// + /// A cached small-integer signal replays its value to both subscription surfaces, as does an uncached one. /// A task representing the asynchronous operation. [Test] public async Task CachedInt32SignalsReplayTheirValueToObserversAndCallbacks() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs index ce772ee7..292806d1 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs @@ -443,10 +443,7 @@ public async Task TakeForwardsRequestedCount() await Assert.That(r.Completed).IsTrue(); } - /// - /// A filtering sink that hands a value to a throwing observer must unsubscribe upstream before it rethrows. - /// Without that teardown the source keeps producing into an observer that has already failed. - /// + /// A filtering sink unsubscribes upstream before it rethrows the observer's failure. /// A task representing the asynchronous operation. [Test] public async Task FilteringSinksReleaseTheUpstreamWhenTheObserverThrows() @@ -481,10 +478,7 @@ public async Task FilteringSinksReleaseTheUpstreamWhenTheObserverThrows() await AssertObserverFailureReleasesUpstream(uniqueBySink, uniqueBy); } - /// - /// The accumulating and counting sinks make the same promise: a throwing observer tears the sink down and - /// unsubscribes upstream rather than leaving a half-live pipeline behind. - /// + /// An accumulating or counting sink tears itself down and unsubscribes upstream when the observer throws. /// A task representing the asynchronous operation. [Test] public async Task AccumulatingAndCountingSinksReleaseTheUpstreamWhenTheObserverThrows() @@ -506,10 +500,7 @@ public async Task AccumulatingAndCountingSinksReleaseTheUpstreamWhenTheObserverT await AssertObserverFailureReleasesUpstream(takeWhileSink, takeWhile); } - /// - /// A hands a window to the observer only when the window fills, so its - /// teardown-on-throw path is reached on the value that closes the window, not on the ones that fill it. - /// + /// A releases the upstream when the observer throws on the window it closes. /// A task representing the asynchronous operation. [Test] public async Task BufferSinkReleasesTheUpstreamWhenTheObserverThrowsOnACompletedWindow() @@ -537,7 +528,7 @@ public async Task BufferSinkDropsValuesFromASourceThatKeepsPushingAfterTheObserv UnstoppableSource source = new(); using var subscription = source.Buffer(Two).Subscribe(observer); - // The window closes on the second value, so that is the first — and only — hand-off to the observer. + // The window closes on the second value, the only hand-off to the observer. source.Next(One); var thrown = Assert.Throws(() => source.Next(Two)); @@ -553,10 +544,7 @@ public async Task BufferSinkDropsValuesFromASourceThatKeepsPushingAfterTheObserv await Assert.That(observer.CompletedCount).IsEqualTo(0); } - /// - /// Every single-source sink forwards a fault to its observer exactly once and releases the upstream - /// subscription as it goes; none of them completes the observer as well. - /// + /// A filtering sink forwards a fault once, releases the upstream, and does not complete the observer. /// A task representing the asynchronous operation. [Test] public async Task FilteringSinksForwardFaultsAndReleaseTheUpstream() @@ -603,7 +591,7 @@ await AssertFaultIsForwardedAndUpstreamReleased( }); } - /// The accumulating and counting sinks forward a fault and release the upstream subscription too. + /// An accumulating or counting sink forwards a fault and releases the upstream subscription. /// A task representing the asynchronous operation. [Test] public async Task AccumulatingAndCountingSinksForwardFaultsAndReleaseTheUpstream() @@ -641,10 +629,7 @@ await AssertFaultIsForwardedAndUpstreamReleased( }); } - /// - /// The take sinks complete early, so a source that has not noticed yet can still push a fault at them. That - /// fault must be dropped: the observer has already been completed and must not then be told it failed. - /// + /// A fault that arrives after a take sink's terminal is dropped rather than forwarded to the observer. /// A task representing the asynchronous operation. [Test] public async Task TakeSinksDropFaultsThatArriveAfterTheirTerminal() @@ -713,7 +698,7 @@ private static void Feed(IObserver sink, IEnumerable values) } } - /// A user-defined accumulator that sums observed values, exercising the public contract. + /// A user-defined that sums observed values. private readonly record struct SumAggregator : IAggregator { /// Initializes a new instance of the struct. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SinkTerminalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SinkTerminalTests.cs index b8b282ef..c8bcfa21 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SinkTerminalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SinkTerminalTests.cs @@ -55,7 +55,7 @@ public async Task CompleteWithoutValueCompletesAndDisposesTheSink() await Assert.That(sink.DisposeCount).IsEqualTo(1); } - /// The sink is disposed via the finally even when the downstream observer throws. + /// The sink is disposed even when the downstream observer throws. /// A task representing the asynchronous operation. [Test] public async Task FaultStillDisposesTheSinkWhenTheObserverThrows() @@ -82,7 +82,7 @@ public async Task FaultWithLatchForwardsAndSetsTheLatchWhenNotDone() await Assert.That(sink.DisposeCount).IsEqualTo(1); } - /// The latched Fault overload does nothing once the latch is already set. + /// The latched Fault overload does nothing when the latch is set. /// A task representing the asynchronous operation. [Test] public async Task FaultWithLatchIsNoOpWhenAlreadyDone() @@ -111,7 +111,7 @@ public async Task CompleteWithValueAndLatchEmitsAndSetsTheLatchWhenNotDone() await Assert.That(sink.DisposeCount).IsEqualTo(1); } - /// The latched value Complete overload does nothing once the latch is already set. + /// The latched value Complete overload does nothing when the latch is set. /// A task representing the asynchronous operation. [Test] public async Task CompleteWithValueAndLatchIsNoOpWhenAlreadyDone() @@ -140,7 +140,7 @@ public async Task CompleteWithoutValueAndLatchCompletesAndSetsTheLatchWhenNotDon await Assert.That(sink.DisposeCount).IsEqualTo(1); } - /// The latched valueless Complete overload does nothing once the latch is already set. + /// The latched valueless Complete overload does nothing when the latch is set. /// A task representing the asynchronous operation. [Test] public async Task CompleteWithoutValueAndLatchIsNoOpWhenAlreadyDone() @@ -180,7 +180,7 @@ private sealed class Recorder : IObserver public void OnNext(T value) => Values.Add(value); } - /// An observer whose throws, to exercise the helper's finally branch. + /// An observer whose throws when a fault is forwarded to it. /// The type of the observed values. private sealed class ThrowingObserver : IObserver { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SparkTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SparkTests.cs index 22f332af..f06d26ed 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SparkTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SparkTests.cs @@ -39,7 +39,7 @@ public async Task CompletedSparksAreEqualPerValueType() await Assert.That(second).IsEqualTo(first); } - /// Exercises on-next spark value, equality, accept overloads, and observable conversion. + /// Verifies an on-next spark reports its value, compares by value, and dispatches every accept overload. /// A task representing the asynchronous operation. [Test] public async Task SparkOnNextCoversValueEqualityAndAcceptOverloads() @@ -81,7 +81,7 @@ await Assert.That(next.Accept(static value => $"fn-next:{value}", static ex => e await Assert.That(observer.Events).Contains("next:42"); } - /// Exercises on-error spark exception, equality, and accept overloads. + /// Verifies an on-error spark carries its exception, compares by value, and dispatches every accept overload. /// A task representing the asynchronous operation. [Test] public async Task SparkOnErrorCoversExceptionEqualityAndAcceptOverloads() @@ -126,7 +126,7 @@ await Assert.That( await Assert.That(observer.Events).Contains("error:spark-error"); } - /// Exercises on-completed spark equality, accept overloads, and observable validation. + /// Verifies an on-completed spark compares by value and dispatches every accept overload. /// A task representing the asynchronous operation. [Test] public async Task SparkOnCompletedCoversEqualityAndAcceptOverloads() @@ -162,7 +162,7 @@ await Assert.That(completed.Accept(static value => value.ToString(), static ex = await Assert.That(observer.Events).Contains(CompletedText); } - /// Verifies a completed spark hashes without a value or an exception to reach for. + /// Verifies two completed sparks produce the same hash code. /// A task representing the asynchronous operation. [Test] public async Task CompletedSparksWithNoValueOrExceptionHashEqual() diff --git a/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs index 2559cff7..96974433 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Tests for 's current-thread requirement, which downstream current-thread -/// operators query to decide whether they must enter the trampoline before subscribing to it. -/// +/// Verifies reports whether it must be subscribed on the current thread. public sealed class StartSignalTests { /// The value produced by the start function. diff --git a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs index 46fd8dd2..3252fc17 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs @@ -34,7 +34,7 @@ public class StateSignalTests /// Expected projected read-only state values. private static readonly string[] ExpectedReadOnlyValues = ["v:10", "v:11", "v:11"]; - /// Covers read-only projection error argument validation. + /// A read-only projection rejects a null error. [Test] public void ReadOnlyStateProjectionValidatesError() { @@ -43,7 +43,7 @@ public void ReadOnlyStateProjectionValidatesError() _ = Assert.Throws(() => projection.OnError(null!)); } - /// Covers read-only projection selector errors forwarded to current and late subscribers. + /// A selector error reaches both the current subscribers and later ones. /// A task representing the asynchronous operation. [Test] public async Task ReadOnlyStateProjectionForwardsSelectorErrorToLateSubscribers() @@ -161,10 +161,7 @@ public async Task ReadOnlyStateProjectionSeedsItselfFromASourceThatHasAlreadyCom await Assert.That(late.Errors.Count).IsEqualTo(0); } - /// - /// Once the projection has completed, a source that keeps notifying must not be able to move the projected - /// value, complete the subscribers twice, or turn a completed projection into a faulted one. - /// + /// A completed projection ignores later values, completions, and faults from its source. /// A task representing the asynchronous operation. [Test] public async Task ReadOnlyStateProjectionIgnoresSourceNotificationsAfterItsTerminal() @@ -186,10 +183,7 @@ public async Task ReadOnlyStateProjectionIgnoresSourceNotificationsAfterItsTermi await Assert.That(observer.Errors.Count).IsEqualTo(0); } - /// - /// Disposing a projection subscription detaches that observer and only that observer; disposing the same - /// handle again is a no-op rather than a second detach. - /// + /// Disposing a projection subscription detaches only that observer, and disposing it twice is a no-op. /// A task representing the asynchronous operation. [Test] public async Task ReadOnlyStateProjectionStopsFeedingADisposedSubscription() @@ -210,10 +204,7 @@ public async Task ReadOnlyStateProjectionStopsFeedingADisposedSubscription() await Assert.That(attached.Values.SequenceEqual(["v:10", "v:11", "v:12"])).IsTrue(); } - /// - /// A disposed projection has thrown its state away, so reading or subscribing to it must fail loudly rather - /// than hand back a stale value. Disposing twice must still be safe. - /// + /// A disposed projection rejects reads and subscriptions, and disposing it twice is safe. /// A task representing the asynchronous operation. [Test] public async Task DisposedReadOnlyStateProjectionRejectsReadsAndSubscriptions() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SubscribeExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SubscribeExtensionsTests.cs index f5efa4ff..2a00db08 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SubscribeExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SubscribeExtensionsTests.cs @@ -45,7 +45,7 @@ public static IDisposable SubscribeAll( } """; - /// A consumer that still uses the plain subscription name with both extension namespaces in scope. + /// A consumer that uses the plain subscription name with both extension namespaces in scope. private const string AmbiguousSubscribeConsumer = """ global using System; global using ReactiveUI.Primitives; @@ -132,7 +132,7 @@ public async Task SubscribePrimitivesCallbacksCompileWithSystemReactiveAndPrimit await Assert.That(symbol?.ContainingType.Name).IsEqualTo(nameof(SubscribeExtensions)); } - /// Verifies the plain Subscribe name stays ambiguous, which is what the explicit name exists to avoid. + /// Verifies the plain Subscribe name stays ambiguous with System.Reactive in scope. /// A task representing the asynchronous operation. [Test] [RequiresAssemblyFiles("Builds metadata references from loaded assembly locations.")] diff --git a/src/tests/ReactiveUI.Primitives.Tests/SwitchMapTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SwitchMapTests.cs index 8ffc7813..2a6afddc 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SwitchMapTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SwitchMapTests.cs @@ -61,10 +61,7 @@ public async Task SwitchMapMirrorsLatestInnerAndIgnoresStaleInner() await Assert.That(values.SequenceEqual(_tenThenTwenty)).IsTrue(); } - /// - /// Verifies that a null source value switches onto whatever the selector returns for it, which detaches the - /// previous inner rather than leaving it attached. - /// + /// Verifies that a null source value switches onto the inner the selector returns for it. /// A task representing the asynchronous operation. [Test] public async Task SwitchMapSwitchesOnNullSourceValue() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.Generation.cs b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.Generation.cs index 5cdfa309..f40f6119 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.Generation.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.Generation.cs @@ -32,7 +32,6 @@ public async Task SwitchWitnessKeepsTheNewestGenerationWhenTheOuterIsReenteredWh // Pushing `first` subscribes it, which emits synchronously, which re-enters the outer with `second`. outer.OnNext(first); - // The replacement remains subscribed after the first subscription returns. second.Observer?.OnNext(SecondValue); using (Assert.Multiple()) @@ -102,7 +101,7 @@ private class RecordingObserver : IObserver /// The inner pushed on the first observed value. private sealed class ReenteringObserver(Signal> outer, IObservable replacement) : RecordingObserver { - /// Whether the re-entrant push has already happened. + /// Whether the re-entrant push has happened. private int _pushed; /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs index 9f0576fc..57298277 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs @@ -18,7 +18,7 @@ public sealed partial class SwitchWitnessTests /// The integer constant two. private const int Two = 2; - /// Verifies direct switch witness completion is emitted once. + /// A duplicate completion from the current inner source is suppressed. /// A task representing the asynchronous operation. [Test] public async Task SwitchWitnessSuppressesDuplicateCompletionFromCurrentInner() @@ -96,7 +96,6 @@ public async Task SwitchWitnessForwardsOnlyLatestInnerValues() outer.OnNext(second); - // The stale first inner is now superseded; its values must be dropped. first.Observer.OnNext(One); second.Observer!.OnNext(Two); @@ -121,11 +120,9 @@ public async Task SwitchWitnessIgnoresStaleInnerCompletion() outer.OnNext(second); outer.OnCompleted(); - // Stale inner completion (wrong version) must not complete the witness. staleFirst.OnCompleted(); await Assert.That(observer.Completed).IsEqualTo(0); - // Current inner completion plus outer-complete completes once. second.Observer!.OnCompleted(); await Assert.That(observer.Completed).IsEqualTo(One); } @@ -143,7 +140,6 @@ public async Task SwitchWitnessDefersCompletionUntilActiveInnerCompletes() outer.OnNext(inner); outer.OnCompleted(); - // Outer done but inner still active: no completion yet. await Assert.That(observer.Completed).IsEqualTo(0); inner.Observer!.OnCompleted(); @@ -181,7 +177,6 @@ public async Task SwitchWitnessForwardsOuterErrorAndGatesAfterwards() await Assert.That(observer.Errors).HasSingleItem(); await Assert.That(observer.Errors[0]).IsSameReferenceAs(error); - // Everything is gated after the terminal error. inner.Observer!.OnNext(One); inner.Observer.OnCompleted(); outer.OnNext(inner); @@ -209,7 +204,6 @@ public async Task SwitchWitnessForwardsInnerErrorAndGatesAfterwards() await Assert.That(observer.Errors).HasSingleItem(); await Assert.That(observer.Errors[0]).IsSameReferenceAs(error); - // Subsequent inner/outer notifications are gated. inner.Observer.OnNext(One); outer.OnCompleted(); @@ -233,7 +227,6 @@ public async Task SwitchWitnessDropsStaleInnerError() var staleFirst = first.Observer!; outer.OnNext(second); - // Error from the superseded inner is dropped, not forwarded. staleFirst.OnError(new InvalidOperationException("stale")); await Assert.That(observer.Errors).IsEmpty(); @@ -256,7 +249,6 @@ public async Task SwitchWitnessIgnoresSourceAfterTerminal() outer.OnCompleted(); await Assert.That(observer.Completed).IsEqualTo(One); - // A late source must be ignored; its observer is never captured. outer.OnNext(inner); await Assert.That(inner.Observer).IsNull(); } @@ -274,11 +266,9 @@ public async Task SwitchWitnessGatesLateNotificationsAfterInnerError() outer.OnNext(first); - // An inner error makes the witness terminal without stopping the outer source. first.Observer!.OnError(new InvalidOperationException("boom")); await Assert.That(observer.Errors).HasSingleItem(); - // Subsequent source switches and outer errors are gated: nothing more is forwarded. outer.OnNext(late); outer.OnError(new InvalidOperationException("late")); @@ -303,7 +293,6 @@ public async Task SwitchWitnessDisposesActiveInnerOnDispose() subscription.Dispose(); - // The inner subscription is disposed by the witness. await Assert.That(inner.Disposed).IsTrue(); } @@ -340,7 +329,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Observer that blocks its first value so any concurrent re-entry is detected with thread-safe state. + /// Observer that blocks inside its first value callback so concurrent re-entry is detected. private sealed class GatedObserver : IObserver, IDisposable { /// Tracks how many threads are currently inside . diff --git a/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.WideArity.cs b/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.WideArity.cs index 3a658ee6..7ea891cb 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.WideArity.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.WideArity.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the wide-arity SyncLatest combinators for ten through sixteen sources: a result is -/// withheld until every source has produced a value, each source lands in its own selector parameter, and a -/// later value from any source replaces only that source's contribution. -/// +/// Verifies the ten- through sixteen-source SyncLatest combinators over their latest values. public partial class SyncLatestTests { /// The number of sources combined by the ten-source overload. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs index 9dd8fc69..d6ec3733 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SyncLatestTests.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Tests; -/// -/// Verifies the multi-source SyncLatest combinators for three through nine sources: a result is -/// withheld until every source has produced a value, each source lands in its own selector parameter, and a -/// later value from any source replaces only that source's contribution. -/// +/// Verifies the three- through nine-source SyncLatest combinators over their latest values. public partial class SyncLatestTests { /// The number of sources combined by the three-source overload. @@ -192,10 +188,7 @@ public async Task SyncLatestRejectsNullSourcesAndSelectors() await Assert.That(sources[0].HasObservers).IsFalse(); } - /// - /// Verifies the combined sequence ignores every notification a source delivers after the sequence has - /// terminated, and ignores a duplicate completion from a single source. - /// + /// Verifies the combined sequence ignores every notification a source delivers after it terminates. /// A task representing the asynchronous operation. [Test] public async Task SyncLatestIgnoresSourceNotificationsDeliveredAfterTheCombinedSequenceTerminates() @@ -226,10 +219,7 @@ public async Task SyncLatestIgnoresSourceNotificationsDeliveredAfterTheCombinedS await Assert.That(witness.Errors.Count).IsEqualTo(0); } - /// - /// Verifies a source that throws while being subscribed tears down the sources already subscribed and - /// surfaces the original failure to the caller. - /// + /// Verifies a source that throws on subscribe tears down the sources it has subscribed and rethrows. /// A task representing the asynchronous operation. [Test] public async Task SyncLatestUnsubscribesEarlierSourcesWhenALaterSourceThrowsOnSubscribe() diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs index 60ad111a..2b79816e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.FromTask.cs @@ -20,7 +20,7 @@ public partial class TaskSignalTests /// Exception message used by user exception tests. private const string BreakExecutionMessage = "break execution"; - /// Covers from-task cancellation callback argument validation. + /// A null cancellation callback is rejected. [Test] public void FromTaskValidatesCancellationCallback() { @@ -35,7 +35,7 @@ public void FromTaskValidatesCancellationCallback() } } - /// Covers from-task result emission and completion. + /// A from-task signal emits the task result and completes. /// A task representing the asynchronous operation. [Test] public async Task FromTaskEmitsResult() diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs index fbf96a62..ab301444 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskSignalTests.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies cancellation and disposal contracts. public partial class TaskSignalTests { - /// Covers task-signal cancellation registration and disposal branches. + /// Disposing a task signal requests cancellation once, and a null factory is rejected. /// A task representing the asynchronous operation. [Test] public async Task TaskSignalCoversCancellationAndDisposeBranches() @@ -49,7 +49,7 @@ public async Task TaskSignalCreateWithASchedulerBuildsASignalThatCancelsOnDispos await Assert.That(taskSignal.IsCancellationRequested).IsTrue(); } - /// Disposal tolerates a token source that the completion path has already disposed. + /// Disposal tolerates a token source that the completion path has disposed. /// A task representing the asynchronous operation. [Test] public async Task TaskSignalDisposeToleratesAnAlreadyDisposedTokenSource() diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalCompletionTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalCompletionTests.cs index 31ba5334..c5b2f90e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalCompletionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalCompletionTests.cs @@ -12,7 +12,7 @@ public class TaskTerminalCompletionTests /// Expected value used by terminal completion assertions. private const int ExpectedValue = 42; - /// Verifies that the terminal task is exposed and resolved with the supplied value. + /// The terminal task resolves with the supplied value. /// A task representing the asynchronous operation. [Test] public async Task TaskReturnsResolvedResult() @@ -56,7 +56,7 @@ public async Task AttachWithCanceledTokenDisposesSubscription() await Assert.That(subscription.DisposeCount).IsEqualTo(1); } - /// Verifies that attaching with an already-canceled token cancels inline and releases the registration. + /// Attaching a subscription to a canceled token cancels inline and releases the registration. /// A task representing the asynchronous operation. [Test] public async Task AttachWithAlreadyCanceledTokenCancelsAndDisposesSubscription() @@ -87,7 +87,7 @@ public async Task FailReleasesSubscriptionAndFaultsTask() await Assert.That(subscription.DisposeCount).IsEqualTo(1); } - /// Verifies the shared empty-source failure shape. + /// An empty-source failure faults the terminal task with the shared empty-source message. /// A task representing the asynchronous operation. [Test] public async Task FailEmptyFaultsWithEmptySourceError() diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalWitnessTests.cs index 6c910e72..865b39ed 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskTerminalWitnessTests.cs @@ -110,7 +110,7 @@ public async Task CountWitnessCancelsItsTaskWhenTheTokenIsCancelledWhileRunning( await Assert.That(upstream.DisposeCount).IsEqualTo(1); } - /// A count witness built on an already-cancelled token cancels inline and drops a late subscription. + /// A count witness built on a cancelled token cancels inline and drops a late subscription. /// A task representing the asynchronous operation. [Test] public async Task CountWitnessOnAnAlreadyCancelledTokenCancelsInlineAndDropsTheSubscription() @@ -145,7 +145,7 @@ public async Task CountWitnessIgnoresCompletionRaisedAfterItStopped() await Assert.That(upstream.DisposeCount).IsEqualTo(1); } - /// The any-witness keeps its result when a cancellation is signalled after it has already stopped. + /// The any-witness keeps its result when a cancellation is signalled after it stopped. /// A task representing the asynchronous operation. [Test] public async Task AnyWitnessIgnoresCancellationSignalledAfterItsResult() @@ -163,7 +163,7 @@ public async Task AnyWitnessIgnoresCancellationSignalledAfterItsResult() await Assert.That(upstream.DisposeCount).IsEqualTo(1); } - /// The count witness keeps its result when a cancellation is signalled after it has already stopped. + /// The count witness keeps its result when a cancellation is signalled after it stopped. /// A task representing the asynchronous operation. [Test] public async Task CountWitnessIgnoresCancellationSignalledAfterItStopped() diff --git a/src/tests/ReactiveUI.Primitives.Tests/TimeIntervalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TimeIntervalTests.cs index 41818de6..cdfa6bd7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TimeIntervalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TimeIntervalTests.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies equality and hashing contracts. public class TimeIntervalTests { - /// An arbitrary non-zero interval; only its stability matters to the hashing contract. + /// A fixed non-zero interval shared by the time-interval tests. private static readonly TimeSpan SampleInterval = TimeSpan.FromMilliseconds(123); /// Verifies a time interval with a null value uses a deterministic null hash code. diff --git a/src/tests/ReactiveUI.Primitives.Tests/UnfoldSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/UnfoldSignalTests.cs index acc432c9..f7a3d6e8 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/UnfoldSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/UnfoldSignalTests.cs @@ -17,7 +17,7 @@ public class UnfoldSignalTests /// The second expected value. private const int Second = 2; - /// Covers unfold subscribe argument validation. + /// Verifies an unfold rejects a null observer. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] public void UnfoldValidatesObserver() => Assert.Throws(static () => Signal.Unfold( @@ -27,7 +27,7 @@ public void UnfoldValidatesObserver() => Assert.Throws(st static value => value) .Subscribe(null!)); - /// Covers unfold emission while the condition holds. + /// Verifies an unfold emits values while the condition holds and then completes. /// A task representing the asynchronous operation. [Test] public async Task UnfoldEmitsWhileConditionHolds() @@ -40,10 +40,7 @@ public async Task UnfoldEmitsWhileConditionHolds() await Assert.That(unfoldCompleted).IsEqualTo(1); } - /// - /// The observer surface must generate the same sequence as the callback surface: the unfold runs to - /// exhaustion on subscribe, and a condition that is false from the start yields nothing but a completion. - /// + /// Verifies an observer receives the generated sequence, or only a completion when the condition starts false. /// A task representing the asynchronous operation. [Test] public async Task UnfoldEmitsTheGeneratedSequenceToObservers() @@ -65,7 +62,7 @@ public async Task UnfoldEmitsTheGeneratedSequenceToObservers() await Assert.That(exhausted.Completed).IsEqualTo(1); } - /// An unfold runs entirely inline on the subscriber's thread, so it never demands a particular one. + /// Verifies an unfold never requires subscription on the current thread. /// A task representing the asynchronous operation. [Test] public async Task UnfoldNeverRequiresCurrentThreadSubscription() diff --git a/src/tests/ReactiveUI.Primitives.Tests/UseSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/UseSignalTests.cs index 7aaa3918..a920f1b8 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/UseSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/UseSignalTests.cs @@ -17,7 +17,7 @@ public class UseSignalTests /// A value pushed after the signal has terminated; it must never be delivered. private const int LateValue = 2; - /// Covers resource disposal when the subscription forwards a null error. + /// A null error from the scoped source is rejected and the resource is released. /// A task representing the asynchronous operation. [Test] public async Task UseValidatesSubscriptionError() @@ -30,7 +30,7 @@ public async Task UseValidatesSubscriptionError() await Assert.That(resource.DisposeCount).IsEqualTo(1); } - /// Covers resource disposal when the inner subscription is null. + /// A null inner subscription is rejected and the resource is released. /// A task representing the asynchronous operation. [Test] public async Task UseDisposesResourceWhenSubscriptionIsNull() @@ -42,10 +42,7 @@ public async Task UseDisposesResourceWhenSubscriptionIsNull() await Assert.That(resource.DisposeCount).IsEqualTo(1); } - /// - /// A fault from the scoped source reaches the observer and releases the resource. The whole point of the - /// operator is that the resource does not outlive the sequence, whichever way the sequence ends. - /// + /// A fault from the scoped source reaches the observer and releases the resource. /// A task representing the asynchronous operation. [Test] public async Task UseForwardsSourceFaultsAndReleasesTheResource() @@ -63,11 +60,7 @@ public async Task UseForwardsSourceFaultsAndReleasesTheResource() await Assert.That(resource.DisposeCount).IsEqualTo(1); } - /// - /// With a hot source, the subscription is handed to the sink while the sequence is still live: the resource - /// stays alive until completion, and every notification the source keeps sending afterwards is dropped - /// rather than re-delivered or re-releasing the resource. - /// + /// A hot source holds the resource until it completes, and notifications after the terminal are dropped. /// A task representing the asynchronous operation. [Test] public async Task UseHoldsTheResourceUntilTheHotSourceCompletesThenIgnoresLateSignals() @@ -96,11 +89,7 @@ public async Task UseHoldsTheResourceUntilTheHotSourceCompletesThenIgnoresLateSi await Assert.That(source.DisposeCount).IsEqualTo(1); } - /// - /// When the observer throws from its value callback, the sink tears itself down — unsubscribing upstream - /// and releasing the resource — and rethrows. A second value must then be dropped, not fed back into the - /// observer that already failed. - /// + /// An observer that throws from its value callback releases the resource and receives no further values. /// A task representing the asynchronous operation. [Test] public async Task UseReleasesTheResourceWhenTheObserverThrows() @@ -113,7 +102,7 @@ public async Task UseReleasesTheResourceWhenTheObserverThrows() var thrown = Assert.Throws(() => sink.OnNext(FirstValue)); - // The sink has stopped, so this must be a silent no-op rather than a second trip into the observer. + // The sink has stopped, so this is a silent no-op. sink.OnNext(LateValue); await Assert.That(thrown!.Message).IsEqualTo("observer-next"); diff --git a/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs index a24afd69..5442995f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/WasmSequencerTests.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Tests; /// Tests for . public sealed class WasmSequencerTests { - /// Expected values produced by an immediate burst, used to verify FIFO order. + /// Expected values produced by an immediate burst, in FIFO order. private static readonly int[] ExpectedBurst = [1, 2, 3]; /// Verifies the shared instance is a singleton. @@ -105,7 +105,7 @@ public async Task PastDueScheduleExecutes() await Assert.That(executed).IsTrue(); } - /// Verifies a cancelled work item never executes while later work still runs. + /// Verifies a cancelled work item never executes while later work runs. /// A representing the asynchronous operation. [Test] public async Task CancelledItemIsSkipped() diff --git a/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs index 4d2cf22d..cd60b818 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs @@ -42,7 +42,6 @@ public async Task DispatchDefersDeliveryToTheSequencer() source.OnNext(First); source.OnNext(Second); - // Nothing may be delivered until the sequencer runs the queued drain. await Assert.That(witness.Values.Count).IsEqualTo(0); clock.AdvanceBy(SingleTick); @@ -76,7 +75,7 @@ public async Task DispatchForwardsSourceErrorsThroughTheSequencer() await Assert.That(witness.Errors[0]).IsSameReferenceAs(expected); } - /// A drain that is torn down mid-flight abandons the notifications still queued behind it. + /// A drain that is torn down mid-flight abandons the notifications queued behind it. /// A task representing the asynchronous operation. [Test] public async Task DispatchAbandonsQueuedNotificationsWhenDisposedMidDrain() diff --git a/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs index 70f62b03..8c36b1a0 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/WitnessTests.cs @@ -95,7 +95,7 @@ public async Task SafeWitnessIgnoresSignalsAfterTerminalAndDisposesOnce() await Assert.That(disposed).IsEqualTo(1); } - /// Covers internal witness implementations and safe observer terminal behavior. + /// Disposed, throwing, and empty witnesses follow their contracts, and a safe witness stops at its terminal. /// A task representing the asynchronous operation. [Test] public async Task WitnessesCoverDisposedThrowEmptyAndSafeBranches() @@ -175,7 +175,7 @@ public async Task WitnessOnDefersNextAndCompletedUntilTheSequencerDrainsThem() await Assert.That(completed).IsEqualTo(1); } - /// Verifies the witness routes a source failure through the same deferred sequencer drain. + /// A source failure is deferred until the sequencer drains it. /// A task representing the asynchronous operation. [Test] public async Task WitnessOnDefersAnErrorUntilTheSequencerDrainsIt() @@ -195,7 +195,7 @@ public async Task WitnessOnDefersAnErrorUntilTheSequencerDrainsIt() await Assert.That(observed).IsSameReferenceAs(error); } - /// Covers callback, forwarding, and stateful witness contracts. + /// Callback, forwarding, stateful, and safe witnesses each forward notifications as their contract requires. /// A task representing the asynchronous operation. [Test] public async Task WitnessImplementationsForwardNotificationsAndFallbackErrors() @@ -207,7 +207,7 @@ public async Task WitnessImplementationsForwardNotificationsAndFallbackErrors() await AssertSafeWitnessIgnoresNotificationsAfterTheTerminal(); } - /// Covers witness factory and safe-wrapper null-callback validation. + /// The witness factories and safe wrappers reject null callbacks and a null error. [Test] public void WitnessFactoriesValidateNullCallbacks() { @@ -223,7 +223,7 @@ public void WitnessFactoriesValidateNullCallbacks() Witness.Create(static _ => { }, static _ => { }, static () => { }).OnError(null!)); } - /// Covers safe-witness error forwarding and post-terminal suppression branches. + /// A safe witness forwards a throwing observer's error, disposes its cancel resource, and drops later faults. /// A task representing the asynchronous operation. [Test] public async Task SafeWitnessForwardsErrorAndIgnoresLateSignals() @@ -932,10 +932,7 @@ public async Task RangeWithLatestSignalCombinesEachLeftValueWithFinalRightValue( _ = Assert.Throws(() => signal.Subscribe(null!)); } - /// - /// Verifies removing an observer the list never held leaves the witness unchanged, so a stray unsubscribe - /// cannot drop a live observer or allocate a new snapshot. - /// + /// Removing an observer the list never held returns the same witness. /// A task representing the asynchronous operation. [Test] public async Task ListWitnessRemoveReturnsTheSameWitnessWhenTheObserverIsNotPresent() From 2e3ea0d933d60a23c31b9e8a45da6856dce95567 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:54:51 +1000 Subject: [PATCH 04/11] further work --- .../Components/ReactiveComponentBase.cs | 9 +- .../Internal/ScheduledDrainState.cs | 12 +- .../Internal/SequencerPeriodicExtensions.cs | 2 +- .../Operators/AsSignalObservable.cs | 2 +- .../Operators/BufferUntilIdleObservable.cs | 4 +- .../Operators/ConflateObservable.cs | 14 +- .../Operators/DebounceImmediateObservable.cs | 9 +- .../Operators/DebounceUntilObservable.cs | 10 +- .../Operators/DetectStaleObservable.cs | 12 +- .../Operators/ForEachObservable.cs | 4 +- .../Operators/FromArrayObservable.cs | 2 +- .../Operators/HeartbeatObservable.cs | 9 +- .../Operators/ObserveOnIfObservable.cs | 15 +- .../Operators/ObserveOnObservable.cs | 7 +- .../Operators/RetryBackoffPolicy.cs | 2 +- .../Operators/RetryWithBackoffObservable.cs | 10 +- .../Operators/StartActionObservable.cs | 5 +- .../Operators/StartFuncObservable.cs | 5 +- .../Concurrency/AvaloniaScheduler.cs | 3 + .../Concurrency/AvaloniaScheduler.cs | 5 +- .../Concurrency/BlazorRendererSequencer.cs | 3 + .../Concurrency/BlazorRendererSequencer.cs | 6 +- .../FirstMatchFromCandidatesObservable.cs | 26 +- .../Operators/LatestOrDefaultObservable.cs | 6 +- .../Operators/WhereSelectObservable.cs | 6 +- .../Concurrency/MauiDispatcherSequencer.cs | 5 + .../Concurrency/MauiDispatcherSequencer.cs | 2 +- .../OperatorTimeSchedulerBenchmarks.cs | 4 +- ...ensionsComparisonBenchmarks.Competitors.cs | 144 ++++----- ...eExtensionsComparisonBenchmarks.Helpers.cs | 166 +++++----- ...mparisonBenchmarks.Library.Supplemental.cs | 306 +++++++++--------- ...eExtensionsComparisonBenchmarks.Library.cs | 216 ++++++------- .../EventGeneratorDriverBenchmarks.cs | 3 +- .../EventGeneratorGcProfileBenchmarks.cs | 1 - .../GeneratorHarness.cs | 6 +- .../DisposableBagTests.cs | 2 +- .../DisposableSlotHelperTests.cs | 2 +- .../ReactiveAsyncTests.cs | 4 +- .../SequencerSchedulingTests.cs | 5 +- .../SequencerTests.cs | 2 +- ...mbineLatestOperatorTests.EnumerableRest.cs | 114 ++----- .../BlazorRendererSequencerTests.cs | 2 +- .../CoalescingDispatchSchedulerTests.cs | 2 +- .../BlazorRendererSequencerTests.cs | 2 +- .../ReactiveComponentBaseTests.cs | 9 +- .../ReactiveExtensionsTests.cs | 2 +- .../SequencerSchedulingExtensionsTests.cs | 5 +- .../AotSafeAssertionExtensions.cs | 6 +- .../ContinuationTests.cs | 10 +- .../CurrentValueSubjectTests.MultiObserver.cs | 15 +- .../CurrentValueSubjectTests.cs | 4 +- .../DisposableExtensionsTests.cs | 6 +- .../Internal/ConcurrencyRaceHelpersTests.cs | 5 +- .../Internal/FirstAsTaskHelperTests.cs | 16 +- .../Internal/FirstAsValueTaskHelperTests.cs | 18 +- .../Internal/ObserverArrayHelpersTests.cs | 5 +- .../Internal/ReduceSinkStateTests.cs | 6 +- .../Internal/TimerSinkStateTests.cs | 9 +- ...ptionExtensionsTests.SchedulerOverloads.cs | 1 - .../ObservableSubscriptionExtensionsTests.cs | 18 +- .../Operators/ConflateObservableTests.cs | 4 +- .../DebounceImmediateObservableTests.cs | 3 +- .../Operators/DetectStaleObservableTests.cs | 11 +- .../FirstMatchFromCandidatesAsyncPathTests.cs | 43 +-- .../FirstMatchFromCandidatesAsyncTests.cs | 6 +- .../Operators/ForEachObservableTests.cs | 3 +- .../Operators/HeartbeatObservableTests.cs | 3 +- .../Operators/LogErrorsObservableTests.cs | 3 +- .../Operators/MinMaxObservableTests.cs | 4 +- .../Operators/ObserveOnIfObservableTests.cs | 23 +- .../Operators/ObserveOnObservableTests.cs | 22 +- .../MauiDispatcherSequencerTests.cs | 12 +- .../MauiDispatcherSequencerTests.cs | 14 +- .../ContainerDisposableTests.cs | 2 +- .../LinqExtensionsTests.cs | 2 +- ...ncerSchedulingExtensionsTests.Overloads.cs | 5 +- .../SequencerTests.cs | 5 +- .../SignalTests.cs | 2 +- .../WasmSchedulerTests.cs | 32 +- .../BlendUniqueTests.cs | 2 +- .../ChooseSwitchSelectTests.cs | 2 +- .../Common/ConsumerCompilation.cs | 4 +- .../Common/MinimalVirtualClock.cs | 2 +- .../GuardedWitnessTests.cs | 2 +- .../PrioritySemaphoreSignalTests.cs | 2 +- .../ReplaySignalTests.cs | 4 +- .../RxNamesTests.Repeat.cs | 2 +- .../RxNamesTests.cs | 2 +- .../SequencerTests.cs | 2 +- .../SignalFactoriesTests.Composition.cs | 2 +- .../SignalFactoriesTests.EventConversion.cs | 2 +- .../SignalFactoriesTests.Scheduling.cs | 2 +- .../SignalOperatorMixinsTests.cs | 4 +- .../SignalRecoverTests.cs | 2 +- .../SignalTests.cs | 4 +- .../SinkObserverTests.cs | 2 +- .../WitnessOnSignalTests.cs | 2 +- .../CrossPlatformPlaceholderTests.cs | 5 +- .../CrossPlatformPlaceholderTests.cs | 5 +- .../CrossPlatformPlaceholderTests.cs | 5 +- .../CrossPlatformPlaceholderTests.cs | 5 +- .../CrossPlatformPlaceholderTests.cs | 5 +- .../CrossPlatformPlaceholderTests.cs | 5 +- 103 files changed, 705 insertions(+), 879 deletions(-) diff --git a/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs b/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs index 039ab19e..5bd07e31 100644 --- a/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs +++ b/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs @@ -12,6 +12,9 @@ namespace ReactiveUI.Primitives.Blazor.Components; #endif /// Base component that tracks reactive subscriptions and refreshes through Blazor's renderer dispatcher. +/// Every observed callback is marshalled onto the renderer dispatcher, so a handler can touch component state +/// directly, and a callback that throws is dispatched to the enclosing error boundary instead of faulting a detached +/// task. Disposing the component disposes every tracked subscription. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public class ReactiveComponentBase : ComponentBase, IDisposable { @@ -44,7 +47,7 @@ public void Dispose() /// Tracks a subscription so it is disposed when the component is disposed. /// The subscription to track. - /// The supplied subscription, or when the component has already been disposed. + /// The supplied subscription, or when the component is disposed. /// is . protected IDisposable Track(IDisposable subscription) { @@ -136,7 +139,7 @@ protected IDisposable Observe( [MethodImpl(MethodImplOptions.AggressiveInlining)] protected Task InvalidateAsync() => InvokeAsync(StateHasChanged); - /// Handles an unhandled subscription error. + /// Handles a subscription error that no onError callback was given for. /// The observed error. /// Always thrown to surface the subscription error. protected virtual void OnObservedError(Exception error) @@ -174,7 +177,7 @@ private async Task InvokeGuardedAsync(Action callback) } } - /// Refreshes the component when requested and when it is still active. + /// Refreshes the component when requested and the component is undisposed. /// A value indicating whether refresh is requested. private void Refresh(bool shouldRefresh) { diff --git a/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs b/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs index c6dd7137..7f622e60 100644 --- a/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs +++ b/src/Primitives.Extensions.Shared/Internal/ScheduledDrainState.cs @@ -36,17 +36,17 @@ internal sealed class ScheduledDrainState(ISequencer scheduler, IDrainTarget /// Gets a value indicating whether the sink has reached a terminal state; read it inside the sink's gate. internal bool Done => _done; - /// Enqueues an OnNext notification and schedules a drain pass if one isn't already running. + /// Enqueues an OnNext notification and schedules a drain pass when none is in flight. /// The value to forward downstream. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void EnqueueNext(T value) => Enqueue(new(DrainNotificationKind.Next, value, null)); - /// Enqueues an OnError notification and schedules a drain pass if one isn't already running. + /// Enqueues an OnError notification and schedules a drain pass when none is in flight. /// The error to forward downstream. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void EnqueueError(Exception error) => Enqueue(new(DrainNotificationKind.Error, default!, error)); - /// Enqueues an OnCompleted notification and schedules a drain pass if one isn't already running. + /// Enqueues an OnCompleted notification and schedules a drain pass when none is in flight. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void EnqueueCompleted() => Enqueue(new(DrainNotificationKind.Completed, default!, null)); @@ -85,7 +85,7 @@ internal bool TryDequeue(out Notification notification) } } - /// Marks the sink done, clears queued notifications, and returns its upstream handle; the caller must hold the gate. + /// Marks the sink done and drops every queued notification, taking the gate itself. internal void Terminate() { lock (_gate) @@ -95,7 +95,7 @@ internal void Terminate() } } - /// Marks the sink done, clears queued notifications, and returns its upstream handle; the caller must hold the gate. + /// Marks the sink done, leaving the queue untouched; the caller must hold the gate. internal void MarkDoneLocked() => _done = true; /// Begins disposal under the gate, returning the upstream subscription so the caller disposes it outside the gate, or when the sink has terminated. @@ -119,7 +119,7 @@ internal void Terminate() return subscription; } - /// Enqueues a notification; claims and schedules a single drain pass if one isn't running. + /// Enqueues a notification and claims the single drain pass when none is in flight. /// The notification to forward to the drain loop. private void Enqueue(in Notification notification) { diff --git a/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs b/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs index e355ba5c..599ff3fb 100644 --- a/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs +++ b/src/Primitives.Extensions.Shared/Internal/SequencerPeriodicExtensions.cs @@ -14,7 +14,7 @@ namespace ReactiveUI.Primitives.Extensions.Internal; /// Periodic scheduling helpers for . internal static class SequencerPeriodicExtensions { - /// Periodic scheduling helpers for a sequencer. + /// Repeating schedule overloads hung off a sequencer instance. /// The scheduler used for each tick. extension(ISequencer scheduler) { diff --git a/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs b/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs index 405cadbb..d179f36d 100644 --- a/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/AsSignalObservable.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Projection operator that emits for every source element without allocating a projection closure. +/// Replaces each source element with , leaving error and completion untouched. /// The element type of the source observable (ignored). /// The source observable whose values are ignored. internal sealed class AsSignalObservable(IObservable source) : IObservable diff --git a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs index 9277f051..9c31fd7d 100644 --- a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs @@ -44,7 +44,7 @@ private sealed class BufferUntilIdleSink( /// The gate protecting state transitions and downstream notification. private readonly Lock _gate = new(); - /// Shared timer / done-flag plumbing. + /// The timer slot and terminal-state flag shared with the operator's handlers. private readonly TimerSinkState> _state = new(downstream); /// The current buffer of elements. @@ -94,7 +94,7 @@ public void Dispose() } } - /// Schedules a flush after the idle period. + /// Replaces any pending flush with one scheduled a further idle period ahead. private void ScheduleFlush() => _state.Timer.Disposable = scheduler.Schedule(idleTime, Flush); /// Flushes the current buffer to the downstream observer. diff --git a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs index 088a5359..c21bc9f8 100644 --- a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs @@ -11,7 +11,12 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Conflates an observable stream by delaying updates that occur within a minimum period. +/// +/// Delivers notifications on and keeps emissions at least +/// apart: a value arriving inside that window is deferred to the end of it and a +/// newer value takes the deferred one's place. Completion waits for a deferred value to land, while an error is +/// forwarded at once and discards it. +/// /// The type of elements in the source sequence. /// The source observable. /// The minimum period between emissions. @@ -48,7 +53,7 @@ internal sealed class ConflateSink : IObserver, IDisposable, IDrainTarget /// The gate protecting the queue, throttle window, and downstream notification. private readonly Lock _gate = new(); - /// Shared queue / scheduled-drain machinery. + /// The notification queue and scheduled-drain bookkeeping shared with the drain loop. private readonly ScheduledDrainState _state; /// The disposable tracking a scheduled deferred emission. @@ -57,7 +62,7 @@ internal sealed class ConflateSink : IObserver, IDisposable, IDrainTarget /// Wall-clock timestamp of the last emission forwarded downstream. private DateTimeOffset _lastUpdateTime = DateTimeOffset.MinValue; - /// Set to when an upstream OnCompleted is queued but a deferred emission is still pending; the completion fires after that emission lands. + /// Set when an upstream OnCompleted arrives while a deferred emission is pending, so the completion fires once that emission lands. private bool _completionRequested; /// Initializes a new instance of the class. @@ -128,8 +133,7 @@ void IDrainTarget.Drain() default: { - // DrainNotificationKind has only three values; the discard arm absorbs - // Completed so the compiler sees an exhaustive switch. + // Completed is the only remaining kind; this arm keeps the switch exhaustive. ForwardCompleted(); return; } diff --git a/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs b/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs index 63703687..aa3b2dce 100644 --- a/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs @@ -10,7 +10,10 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Debounces a sequence but emits the first value immediately. +/// +/// Emits the first source value inline, then holds each later value for and emits only the +/// most recent one. A terminal notification flushes a pending value before it is forwarded. +/// /// The type of elements in the source sequence. /// The source observable. /// The debounce duration. @@ -44,7 +47,7 @@ private sealed class DebounceImmediateSink( /// The gate for thread safety. private readonly Lock _gate = new(); - /// The timer for debouncing. + /// The pending debounce timer, replaced whenever a newer value arrives. private readonly SwapDisposable _timer = new(); /// Whether the first value has been emitted. @@ -126,7 +129,7 @@ public void Dispose() } } - /// Emits the last value if any. + /// Emits the waiting value, if there is one, and clears it. private void Emit() { T? toEmit; diff --git a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs index e92e72d6..e7507d5f 100644 --- a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs @@ -10,7 +10,11 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Debounces a sequence until a condition becomes true for an element. +/// +/// Forwards a value inline when holds for it, cancelling any pending emission, and +/// otherwise emits it once elapses with no newer value. An error or completion drops a +/// pending value. +/// /// The type of elements in the source sequence. /// The source observable. /// The debounce duration. @@ -49,7 +53,7 @@ private sealed class DebounceUntilSink( /// The gate protecting state transitions and downstream notification. private readonly Lock _gate = new(); - /// Shared timer / done-flag plumbing. + /// The timer slot and terminal-state flag shared with the operator's handlers. private readonly TimerSinkState _state = new(downstream); /// @@ -104,7 +108,7 @@ public void Dispose() } } - /// Emits a debounced value when the sink is still active. + /// Emits the debounced value unless the sink has terminated. /// The debounced value. private void EmitDebounced(T value) { diff --git a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs index bb69cb0d..c68c354c 100644 --- a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs @@ -11,7 +11,11 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Detects when a sequence becomes stale (no emissions for a specified period). +/// +/// Wraps each source value as an update and emits a staleness marker when passes +/// without one, measured from subscription. The window re-arms on every value, so a single quiet stretch yields one +/// marker. +/// /// The type of elements in the source sequence. /// The source observable. /// The period after which the sequence is considered stale. @@ -46,7 +50,7 @@ private sealed class DetectStaleSink( /// The gate protecting state transitions and downstream notification. private readonly Lock _gate = new(); - /// Shared timer / done-flag plumbing. + /// The timer slot and terminal-state flag shared with the operator's handlers. private readonly TimerSinkState> _state = new(downstream); /// Upstream subscription handle, set once via and disposed with the sink. @@ -68,7 +72,7 @@ public void AttachSourceSubscription(IDisposable subscription) } } - /// Initializes the staleness timer. + /// Arms the first staleness window, which the caller does at subscribe time. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Initialize() => ScheduleStale(); @@ -116,7 +120,7 @@ public void Dispose() Interlocked.Exchange(ref _sourceSubscription, null)?.Dispose(); } - /// Arms the staleness timer, which re-arms on every upstream emission without allocating a per-arm closure. + /// Arms the staleness timer, replacing any window that is counting down. private void ScheduleStale() => _state.Timer.Disposable = scheduler.Schedule(this, stalenessPeriod, static (_, self) => self.OnStaleTimer()); diff --git a/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs b/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs index 3e6cf57e..04866ed4 100644 --- a/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ForEachObservable.cs @@ -10,10 +10,10 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Flattening operator that subscribes to an upstream sequence of batches and emits each contained element individually. +/// Emits each element of every the source produces, in order, ignoring a null batch. /// The element type emitted to the downstream observer. /// The source observable of enumerables. -/// An optional scheduler used to marshal source notifications onto. +/// An optional scheduler that source notifications are marshalled onto. internal sealed class ForEachObservable( IObservable> source, ISequencer? scheduler) : IObservable diff --git a/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs b/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs index c41cbcbf..9b155fa5 100644 --- a/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/FromArrayObservable.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// Factory operator that emits each element of an to a single subscriber and then completes, optionally marshalling the emission through a scheduler. /// The element type emitted to the downstream observer. /// The source enumerable whose elements are pumped on subscription. -/// An optional scheduler used to dispatch the pump. +/// An optional scheduler that dispatches the pump. internal sealed class FromArrayObservable( IEnumerable source, ISequencer? scheduler) : IObservable diff --git a/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs b/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs index 3c73db56..baaf1589 100644 --- a/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/HeartbeatObservable.cs @@ -11,7 +11,10 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Injects heartbeat values into the sequence when the source remains quiet for a specified period. +/// +/// Wraps each source value as an update and emits a heartbeat every that the source +/// stays quiet, restarting the timer on each value. +/// /// The type of elements in the source sequence. /// The source observable. /// The period between heartbeats. @@ -71,7 +74,7 @@ public void AttachSourceSubscription(IDisposable subscription) } } - /// Initializes the heartbeat timer. + /// Starts the heartbeat timer, which the caller does at subscribe time. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Initialize() => ScheduleHeartbeats(); @@ -137,7 +140,7 @@ public void Dispose() subscription?.Dispose(); } - /// Schedules the next heartbeat. + /// Restarts the periodic heartbeat timer, dropping the one it replaces. private void ScheduleHeartbeats() { lock (_gate) diff --git a/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs b/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs index 574dc407..8ec4147d 100644 --- a/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs @@ -11,7 +11,12 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Conditionally switches between two schedulers based on a reactive condition. +/// +/// Schedules each source value onto or according to +/// the latest value from , using the false scheduler until the condition first emits. Error +/// and completion are forwarded on the producer's thread, so a terminal notification can overtake scheduled values, and +/// the condition's own error or completion is ignored. +/// /// The type of elements in the source sequence. /// The source observable. /// The reactive condition observable. @@ -67,7 +72,7 @@ private sealed class ObserveOnIfSink( /// The scheduler to use when condition is false. private readonly ISequencer _falseScheduler = InvalidOperationExceptionHelper.Check(falseScheduler); - /// The current scheduler. + /// The scheduler chosen by the latest condition value. private ISequencer _currentScheduler = falseScheduler; /// The last condition value. @@ -79,7 +84,7 @@ private sealed class ObserveOnIfSink( /// Whether the sequence is done. private bool _done; - /// Gets the condition observer. + /// Gets a new observer that feeds condition values into this sink. public IObserver ConditionObserver => new ConditionObserverImpl(this); /// @@ -157,7 +162,7 @@ private void UpdateCondition(bool conditionValue) } } - /// Forwards a scheduled value to the downstream observer if the sink is still active. + /// Forwards a scheduled value downstream unless the sink has terminated. /// The value to forward. private void ForwardNext(T value) { @@ -172,7 +177,7 @@ private void ForwardNext(T value) _downstream.OnNext(value); } - /// Observer for condition updates. + /// Observer that pushes condition values into the sink and ignores the condition's terminal notifications. /// The owning sink. private sealed class ConditionObserverImpl(ObserveOnIfSink sink) : IObserver { diff --git a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs index e274fa86..0c8eae16 100644 --- a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs @@ -23,8 +23,7 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(scheduler); ArgumentExceptionHelper.ThrowIfNull(observer); - // The immediate scheduler runs scheduled work inline on the calling thread, so the - // queue-and-drain machinery would be pure overhead: forward straight through. + // The immediate sequencer runs scheduled work inline, so queue-and-drain would change nothing. if (ReferenceEquals(scheduler, Sequencer.Immediate)) { return source.Subscribe(observer); @@ -44,7 +43,7 @@ private sealed class ObserveOnSink : IObserver, IDisposable, IDrainTarget /// The gate protecting the queue and terminal state. private readonly Lock _gate = new(); - /// Shared queue / scheduled-drain machinery. + /// The notification queue and scheduled-drain bookkeeping shared with the drain loop. private readonly ScheduledDrainState _state; /// Initializes a new instance of the class. @@ -104,7 +103,7 @@ void IDrainTarget.Drain() default: { - // DrainNotificationKind has only three values; the discard arm absorbs Completed. + // Completed is the only remaining kind; this arm keeps the switch exhaustive. _state.Terminate(); _downstream.OnCompleted(); return; diff --git a/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs b/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs index c10ff22d..28d00ff7 100644 --- a/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs +++ b/src/Primitives.Extensions.Shared/Operators/RetryBackoffPolicy.cs @@ -13,7 +13,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// Delay before the first retry. /// Multiplier applied to the delay per retry attempt. /// Cap on the computed delay, or for no cap. -/// Scheduler used to schedule each delay. +/// Scheduler that times each retry delay. /// Optional callback invoked on every upstream error. internal readonly record struct RetryBackoffPolicy( int MaxRetries, diff --git a/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs b/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs index e05170f4..3443f43b 100644 --- a/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs @@ -11,7 +11,11 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Retries the source observable sequence upon error, with optional delay, retry count, and backoff. +/// +/// Re-subscribes the source after an error, waiting the policy's initial delay scaled by its backoff factor for each +/// attempt and capped by its maximum, then forwards the error once the retry budget is spent. Every error reaches the +/// policy's callback, and values forwarded before a retry are not retracted. +/// /// The type of elements in the source sequence. /// The source observable. /// The retry / backoff configuration. @@ -46,13 +50,13 @@ private sealed class RetryWithBackoffSink( /// The subscription to the source sequence. private readonly MutableDisposable _subscription = new(); - /// The number of retries already attempted. + /// The number of retries attempted so far. private int _retries; /// Whether the sink has been disposed. private bool _disposed; - /// Starts the retry process. + /// Subscribes the source for the first attempt. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Run() => SubscribeToSource(); diff --git a/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs b/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs index 17ed6a77..88f5a838 100644 --- a/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs @@ -10,7 +10,10 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Operator that runs an action and completes immediately. +/// +/// Runs the action once per subscription, inline or on , then emits +/// and completes. An exception from the action is forwarded as an error instead. +/// /// The action to run. /// An optional scheduler; null runs the action synchronously inline. internal sealed class StartActionObservable(Action action, ISequencer? scheduler) : IObservable diff --git a/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs b/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs index 031020ca..315e0336 100644 --- a/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs @@ -10,7 +10,10 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Runs the function once — inline when no scheduler is supplied, otherwise on the scheduler — emits the result, then completes. +/// +/// Runs the function once per subscription, inline or on , then emits the result and +/// completes. An exception from the function is forwarded as an error instead. +/// /// Result type. /// Function to run. /// Optional scheduler; null runs inline. diff --git a/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs b/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs index 658b70e2..0288932d 100644 --- a/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs +++ b/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs @@ -8,6 +8,9 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// Avalonia UI-thread scheduler that coalesces scheduled work onto a dispatcher drain. +/// Work runs on the dispatcher's thread at and delayed work fires on a +/// , so disposing the returned subscription stops that timer as well as suppressing work +/// that has not started. /// [System.Diagnostics.DebuggerDisplay("AvaloniaScheduler: Dispatcher = {Dispatcher}, Priority = {Priority}")] public sealed class AvaloniaScheduler : CoalescingDispatchScheduler diff --git a/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs b/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs index 3dd23c31..4a556500 100644 --- a/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs +++ b/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs @@ -9,6 +9,9 @@ namespace ReactiveUI.Primitives.Concurrency; /// Avalonia UI-thread scheduler that coalesces scheduled work onto a dispatcher drain. +/// Work runs on the dispatcher's thread at , one batch per posted drain; scheduling from +/// that thread queues the item for the next drain rather than running it inline. Delayed work fires on a +/// , and an item cancelled before its drain reaches it is skipped. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class AvaloniaScheduler : ISequencer @@ -89,7 +92,7 @@ private void ScheduleDelayed(IWorkItem item, long dueTimestamp) timer.Start(); } - /// Forwards the cached drain callback to the engine. + /// Runs one queued batch on the coalescing engine. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RunDrain() => _state.RunDrain(); } diff --git a/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs b/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs index cdb1339c..97069fa7 100644 --- a/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs +++ b/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs @@ -9,6 +9,9 @@ namespace ReactiveUI.Primitives.Blazor.Reactive.Concurrency; /// Scheduler that coalesces scheduled work through a Blazor renderer dispatcher delegate. +/// Work runs on the renderer's dispatcher, and delayed work waits on a background scheduler that marshals it +/// back through the renderer when due. A renderer task that faults reaches +/// . /// [System.Diagnostics.DebuggerDisplay("BlazorRendererSequencer: InvokeAsync = {_invokeAsync}, UnhandledExceptionHandler = {UnhandledExceptionHandler}")] public sealed class BlazorRendererSequencer : CoalescingDispatchScheduler diff --git a/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs b/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs index 83fe39c5..0f6434bb 100644 --- a/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs +++ b/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs @@ -11,6 +11,10 @@ namespace ReactiveUI.Primitives.Blazor.Concurrency; /// Sequencer that coalesces scheduled work through a Blazor renderer dispatcher delegate. +/// Work runs on the renderer's dispatcher, one batch per posted drain; scheduling from that thread queues the +/// item for the next drain rather than running it inline. Delayed work waits on a shared timer that marshals it back +/// through the renderer when due, and a renderer task that faults reaches +/// . /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class BlazorRendererSequencer : ISequencer @@ -105,7 +109,7 @@ private void HandleFault(Exception exception) ExceptionDispatchInfo.Capture(exception)); } - /// Forwards the cached drain callback to the engine. + /// Runs one queued batch on the coalescing engine. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RunDrain() => _state.RunDrain(); } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs index eaac15fc..2e3d981a 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs @@ -19,7 +19,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// Synchronous transform applied to each raw value to produce the result. /// Returns when a transformed value is a match. /// Value emitted when no candidate matches. -/// Synchronous projections run on the caller's thread. Asynchronous projections retain their state until callbacks complete. +/// A projection that completes synchronously runs on the subscribing thread; one that does not keeps the walk alive until its callbacks arrive. public sealed class FirstMatchFromCandidatesObservable( IReadOnlyList candidates, Func> project, @@ -46,12 +46,11 @@ public IDisposable Subscribe(IObserver observer) return TrySyncLoop(observer); } - /// Tries a synchronous fast-path: each candidate's projected observable is. + /// Walks the candidates inline, handing over to an asynchronous sink at the first projection that does not complete synchronously. /// The downstream observer. /// The subscription disposable. internal IDisposable TrySyncLoop(IObserver observer) { - // The thread-local probe is reset between subscriptions. var probe = SyncProbe.RentForCurrentThread(); for (var i = 0; i < candidates.Count; i++) @@ -104,15 +103,11 @@ internal IDisposable TrySyncLoop(IObserver observer) return EmptyDisposable.Instance; } - /// - /// Lightweight observer used by the synchronous fast-path to capture the result - /// of a one-shot projection. Cheaper than because it - /// carries no downstream observer, candidate list, or delegate references. - /// + /// Observer that records one candidate projection's synchronous outcome — value, error and termination — for the inline walk. [System.Diagnostics.DebuggerDisplay("SyncProbe: Completed = {Completed}, HasValue = {HasValue}, Value = {Value}")] public sealed class SyncProbe : IObserver { - /// Per-thread cached instance; rented on entry to TrySyncLoop and returned on exit. Eliminates the per-subscribe allocation on the fast path. + /// Per-thread cached instance, rented on entry to the inline walk and returned on exit. [ThreadStatic] private static SyncProbe? _cached; @@ -174,7 +169,7 @@ internal void Reset() } } - /// Heap-allocated observer used when a projection does not complete synchronously. Walks the remaining candidates via async callbacks. + /// Observer that walks the remaining candidates through asynchronous callbacks once a projection defers. /// The downstream observer. /// The candidate list. /// The projection delegate. @@ -200,7 +195,7 @@ private sealed class AsyncSink( /// One once the sink has reached a terminal state; otherwise zero. private int _done; - /// Whether the sink is currently looping through candidates. + /// Set while walks candidates, so a terminal callback from the inline subscription does not advance the walk re-entrantly. private bool _looping; /// @@ -245,7 +240,7 @@ public void OnError(Exception error) if (_looping) { - // Sync-completion is captured by the probe in TryNext; nothing more to do here. + // The walk in TryNext reads this terminal notification off the probe instead. return; } @@ -257,10 +252,7 @@ public void OnError(Exception error) "Design", "SST2318:Members should not have identical bodies", Justification = - "A candidate that completes and a candidate that errors both mean the same thing to this operator: that " - + "candidate produced no match, so advance to the next one. OnError and OnCompleted are distinct " - + "IObserver channels that deliberately share this advance logic; collapsing them would lose the " - + "ability to give candidate errors their own policy later.")] + "A candidate that errors and a candidate that completes both mean no match, so both channels advance the walk.")] public void OnCompleted() { if (Volatile.Read(ref _done) != 0) @@ -270,7 +262,7 @@ public void OnCompleted() if (_looping) { - // Sync-completion is captured by the probe in TryNext; nothing more to do here. + // The walk in TryNext reads this terminal notification off the probe instead. return; } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs index 114dc5f1..7084e744 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/LatestOrDefaultObservable.cs @@ -6,13 +6,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Emits on subscribe, then each source value that differs from the one emitted before -/// it under . -/// +/// Emits the default value on subscribe, then each source value that differs from the one before it. /// The type of elements in the source sequence. /// The source observable. /// The value to emit initially. +/// Equality is decided by . public sealed class LatestOrDefaultObservable( IObservable source, T defaultValue) : IObservable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs index 36e48a61..2ca33a4b 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WhereSelectObservable.cs @@ -6,15 +6,13 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Emits applied to each source element that accepts. An -/// exception from either delegate terminates the sequence. -/// +/// Emits each source element the predicate accepts, projected by the selector. /// The source element type. /// The projected element type after applying the selector. /// The source observable to filter and project. /// Predicate applied to each source element; only elements returning are forwarded through . /// Projection applied to elements that pass . +/// An exception from either delegate terminates the sequence. public sealed class WhereSelectObservable( IObservable source, Func predicate, diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs index 702aa790..c159f795 100644 --- a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs @@ -8,6 +8,9 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// MAUI dispatcher scheduler that coalesces scheduled work through an . +/// Work runs on the dispatcher's thread. Delayed work goes to the dispatcher's own delayed dispatch, which +/// cannot be called off once armed, so disposing the returned subscription suppresses the action without cancelling +/// the delay. /// [System.Diagnostics.DebuggerDisplay("MauiDispatcherSequencer: Dispatcher = {Dispatcher}")] public sealed class MauiDispatcherSequencer : CoalescingDispatchScheduler @@ -28,6 +31,8 @@ public MauiDispatcherSequencer(IDispatcher dispatcher) => protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) { _ = Dispatcher.DispatchDelayed(dueTime, work); + + // The dispatcher hands back no handle for an armed delay, so there is nothing to stop here. return Disposable.Empty; } } diff --git a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs index 182a0c42..ee33076d 100644 --- a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs @@ -64,7 +64,7 @@ private void ScheduleDelayed(IWorkItem item, long dueTimestamp) => DispatchSequencerState.DelayUntil(dueTimestamp), () => DispatchSequencerState.RunIfActive(item)); - /// Forwards the cached drain callback to the engine. + /// Runs one queued batch on the coalescing engine. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void RunDrain() => _state.RunDrain(); } diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTimeSchedulerBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTimeSchedulerBenchmarks.cs index 3a6e277a..3d09fb42 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTimeSchedulerBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/OperatorTimeSchedulerBenchmarks.cs @@ -362,9 +362,7 @@ private sealed class ImmediateSynchronizationContext : SynchronizationContext, I "Design", "SST2318:Members should not have identical bodies", Justification = - "Post and Send are distinct SynchronizationContext overrides that this immediate context deliberately " - + "implements the same way: run the callback inline. They are separate base-class overrides and cannot " - + "be collapsed.")] + "Post and Send are distinct SynchronizationContext overrides and cannot be collapsed into one.")] public override void Send(SendOrPostCallback d, object? state) => d(state); /// Releases the resources used by the synchronization context. diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Competitors.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Competitors.cs index c0e9a000..0181c496 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Competitors.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Competitors.cs @@ -13,32 +13,32 @@ namespace ReactiveUI.Primitives.Benchmarks; /// Benchmarks the complete synchronous ReactiveUI.Primitives.Extensions public helper surface. public partial class ReactiveExtensionsComparisonBenchmarks { - /// Executes the SystemReactiveAsSignal benchmark helper. - /// The SystemReactiveAsSignal result. + /// Projects the System.Reactive range to unit values and drains it. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveAsSignal() => DrainPrimitiveUnit(RxObservable.Select(RxObservable.Range(0, Count), static _ => RxVoid.Default)); - /// Executes the SystemReactiveCatchAndReturn benchmark helper. - /// The SystemReactiveCatchAndReturn result. + /// Substitutes a fallback value for a System.Reactive failure. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveCatchAndReturn() => DrainInt(RxObservable.Throw(Boom).Catch(RxObservable.Return(Fallback))); - /// Executes the SystemReactiveCatchIgnore benchmark helper. - /// The SystemReactiveCatchIgnore result. + /// Swallows a System.Reactive failure and completes empty. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveCatchIgnore() => DrainInt(RxObservable.Throw(Boom).Catch(RxObservable.Empty())); - /// Executes the SystemReactiveCombineLatestValuesAreAllFalse benchmark helper. - /// The SystemReactiveCombineLatestValuesAreAllFalse result. + /// Combines two false System.Reactive sources and tests that every value is false. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveCombineLatestValuesAreAllFalse() => DrainBool(BoolSources(ExtensionsLibrary.ReactiveUIExtensions, false).CombineLatest(ValuesAreAllFalse)); - /// Executes the SystemReactiveCombineLatestValuesAreAllTrue benchmark helper. - /// The SystemReactiveCombineLatestValuesAreAllTrue result. + /// Combines two true System.Reactive sources and tests that every value is true. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveCombineLatestValuesAreAllTrue() => DrainBool(BoolSources(ExtensionsLibrary.ReactiveUIExtensions, true).CombineLatest(ValuesAreAllTrue)); @@ -75,28 +75,28 @@ private static bool ValuesAreAllTrue(IList values) return true; } - /// Executes the SystemReactiveFilter benchmark helper. - /// The SystemReactiveFilter result. + /// Filters the System.Reactive string source by the even-digit regex. + /// The scenario checksum. private static int SystemReactiveFilter() { var regex = EvenRegex(); return DrainString(RxObservable.Where(RxObservable.ToObservable(StringValues), value => regex.IsMatch(value))); } - /// Executes the SystemReactiveForEach benchmark helper. - /// The SystemReactiveForEach result. + /// Flattens a single batch of values through System.Reactive. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveForEach() => DrainInt(RxObservable.Return(Values.AsEnumerable()).SelectMany(static values => values)); - /// Executes the SystemReactiveFromArray benchmark helper. - /// The SystemReactiveFromArray result. + /// Drains the shared int array through System.Reactive. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveFromArray() => DrainInt(RxObservable.ToObservable(Values)); - /// Executes the SystemReactiveGetMax benchmark helper. - /// The SystemReactiveGetMax result. + /// Combines two System.Reactive scalars into their maximum. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveGetMax() => DrainInt(RxObservable.CombineLatest( @@ -104,8 +104,8 @@ private static int SystemReactiveGetMax() => RxObservable.Return(SecondValue), Math.Max)); - /// Executes the SystemReactiveGetMin benchmark helper. - /// The SystemReactiveGetMin result. + /// Combines two System.Reactive scalars into their minimum. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveGetMin() => DrainInt(RxObservable.CombineLatest( @@ -113,14 +113,14 @@ private static int SystemReactiveGetMin() => RxObservable.Return(SecondValue), Math.Min)); - /// Executes the SystemReactiveNot benchmark helper. - /// The SystemReactiveNot result. + /// Negates each boolean emitted by the System.Reactive source. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveNot() => DrainBool(RxObservable.Select(RxObservable.ToObservable(BooleanValues), static value => !value)); - /// Executes the SystemReactivePairwise benchmark helper. - /// The SystemReactivePairwise result. + /// Rebuilds pairwise semantics from a System.Reactive sliding buffer. + /// The scenario checksum. private static int SystemReactivePairwise() { PairWitness observer = new(); @@ -133,96 +133,96 @@ private static int SystemReactivePairwise() return observer.Total; } - /// Executes the SystemReactiveReturn benchmark helper. - /// The SystemReactiveReturn result. + /// Drains a single-value System.Reactive source. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveReturn() => DrainInt(RxObservable.Return(Value)); - /// Executes the SystemReactiveScanWithInitial benchmark helper. - /// The SystemReactiveScanWithInitial result. + /// Accumulates the System.Reactive range from a seed value. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveScanWithInitial() => DrainInt(RxObservable.Scan(RxObservable.Range(0, Count), 0, static (acc, value) => acc + value)); - /// Executes the SystemReactiveSelectAsyncScenario benchmark helper. - /// The SystemReactiveSelectAsyncScenario result. + /// Projects each System.Reactive value through a completed task. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveSelectAsyncScenario() => DrainInt(RxObservable.SelectMany( RxObservable.Range(0, Count), static value => RxObservable.FromAsync(() => Task.FromResult(value + 1)))); - /// Executes the SystemReactiveSelectConstant benchmark helper. - /// The SystemReactiveSelectConstant result. + /// Replaces every value in the System.Reactive range with a constant. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveSelectConstant() => DrainInt(RxObservable.Select(RxObservable.Range(0, Count), static _ => Value)); - /// Executes the SystemReactiveSelectManyThen benchmark helper. - /// The SystemReactiveSelectManyThen result. + /// Chains two sequential System.Reactive projections. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveSelectManyThen() => DrainInt(RxObservable.SelectMany( RxObservable.SelectMany(RxObservable.Return(Value), static value => RxObservable.Return(value + 1)), static value => RxObservable.Return(value + 1))); - /// Executes the SystemReactiveSkipWhileNull benchmark helper. - /// The SystemReactiveSkipWhileNull result. + /// Skips the leading nulls of the System.Reactive string source. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveSkipWhileNull() => DrainString(RxObservable.Select( RxObservable.ToObservable(NullableStrings).SkipWhile(static value => value is null), static value => value!)); - /// Executes the SystemReactiveTakeUntil benchmark helper. - /// The SystemReactiveTakeUntil result. + /// Truncates the System.Reactive range at the match threshold. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveTakeUntil() => DrainInt(RxObservable.Range(0, Count).TakeWhile(static value => value <= Match)); - /// Executes the SystemReactiveToHotTask benchmark helper. - /// The SystemReactiveToHotTask result. + /// Converts a single-value System.Reactive source to a task and waits for it. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveToHotTask() => GetCompletedResult(System.Reactive.Threading.Tasks.TaskObservableExtensions.ToTask(RxObservable.Return(Value))); - /// Executes the SystemReactiveWaitUntil benchmark helper. - /// The SystemReactiveWaitUntil result. + /// Takes the first System.Reactive value that matches the threshold. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveWaitUntil() => DrainInt(RxObservable.Range(0, Count).FirstAsync(static value => value == Match)); - /// Executes the SystemReactiveWhereFalse benchmark helper. - /// The SystemReactiveWhereFalse result. + /// Keeps only the false values of the System.Reactive boolean source. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveWhereFalse() => DrainBool(RxObservable.Where(RxObservable.ToObservable(BooleanValues), static value => !value)); - /// Executes the SystemReactiveWhereIsNotNull benchmark helper. - /// The SystemReactiveWhereIsNotNull result. + /// Keeps only the non-null values of the System.Reactive string source. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveWhereIsNotNull() => DrainString(RxObservable.Select( RxObservable.Where(RxObservable.ToObservable(NullableStrings), static value => value is not null), static value => value!)); - /// Executes the SystemReactiveWhereSelect benchmark helper. - /// The SystemReactiveWhereSelect result. + /// Filters the System.Reactive range to even values and scales them. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveWhereSelect() => DrainInt(RxObservable.Select( RxObservable.Where(RxObservable.Range(0, Count), static value => (value & 1) == 0), static value => value * ResultMultiplier)); - /// Executes the SystemReactiveWhereTrue benchmark helper. - /// The SystemReactiveWhereTrue result. + /// Keeps only the true values of the System.Reactive boolean source. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int SystemReactiveWhereTrue() => DrainBool(RxObservable.Where(RxObservable.ToObservable(BooleanValues), static value => value)); - /// Executes the R3AsSignal benchmark helper. - /// The R3AsSignal result. + /// Projects the R3 range to a constant and drains it. + /// The scenario checksum. private static int R3AsSignal() { IntR3Witness observer = new(); @@ -231,8 +231,8 @@ private static int R3AsSignal() return observer.Total; } - /// Executes the R3CatchAndReturn benchmark helper. - /// The R3CatchAndReturn result. + /// Substitutes a fallback value for an R3 failure. + /// The scenario checksum. private static int R3CatchAndReturn() { IntR3Witness observer = new(); @@ -243,8 +243,8 @@ private static int R3CatchAndReturn() return observer.Total; } - /// Executes the R3CatchIgnore benchmark helper. - /// The R3CatchIgnore result. + /// Swallows an R3 failure and completes empty. + /// The scenario checksum. private static int R3CatchIgnore() { IntR3Witness observer = new(); @@ -255,8 +255,8 @@ private static int R3CatchIgnore() return observer.Total; } - /// Executes the R3FromArray benchmark helper. - /// The R3FromArray result. + /// Drains the shared int array through R3. + /// The scenario checksum. private static int R3FromArray() { IntR3Witness observer = new(); @@ -264,8 +264,8 @@ private static int R3FromArray() return observer.Total; } - /// Executes the R3Not benchmark helper. - /// The R3Not result. + /// Negates each boolean emitted by the R3 source. + /// The scenario checksum. private static int R3Not() { R3BoolWitness observer = new(); @@ -276,8 +276,8 @@ private static int R3Not() return observer.Total; } - /// Executes the R3Return benchmark helper. - /// The R3Return result. + /// Drains a single-value R3 source. + /// The scenario checksum. private static int R3Return() { IntR3Witness observer = new(); @@ -285,8 +285,8 @@ private static int R3Return() return observer.Total; } - /// Executes the R3SelectConstant benchmark helper. - /// The R3SelectConstant result. + /// Replaces every value in the R3 range with a constant. + /// The scenario checksum. private static int R3SelectConstant() { IntR3Witness observer = new(); @@ -295,8 +295,8 @@ private static int R3SelectConstant() return observer.Total; } - /// Executes the R3WhereFalse benchmark helper. - /// The R3WhereFalse result. + /// Keeps only the false values of the R3 boolean source. + /// The scenario checksum. private static int R3WhereFalse() { R3BoolWitness observer = new(); @@ -307,8 +307,8 @@ private static int R3WhereFalse() return observer.Total; } - /// Executes the R3WhereIsNotNull benchmark helper. - /// The R3WhereIsNotNull result. + /// Keeps only the non-null values of the R3 string source. + /// The scenario checksum. private static int R3WhereIsNotNull() { R3CountingWitness observer = new(); @@ -318,8 +318,8 @@ private static int R3WhereIsNotNull() return observer.ItemCount; } - /// Executes the R3WhereSelect benchmark helper. - /// The R3WhereSelect result. + /// Filters the R3 range to even values and scales them. + /// The scenario checksum. private static int R3WhereSelect() { IntR3Witness observer = new(); @@ -330,8 +330,8 @@ private static int R3WhereSelect() return observer.Total; } - /// Executes the R3WhereTrue benchmark helper. - /// The R3WhereTrue result. + /// Keeps only the true values of the R3 boolean source. + /// The scenario checksum. private static int R3WhereTrue() { R3BoolWitness observer = new(); diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs index e123f901..1a25710e 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Helpers.cs @@ -18,24 +18,24 @@ namespace ReactiveUI.Primitives.Benchmarks; /// Benchmarks the complete synchronous ReactiveUI.Primitives.Extensions public helper surface. public partial class ReactiveExtensionsComparisonBenchmarks { - /// Executes the ArraySource benchmark helper. - /// The library value. - /// The ArraySource result. + /// Creates an int source over the shared array from the selected library. + /// The library implementation to exercise. + /// An observable over . private static IObservable ArraySource(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.FromArray(Values) : PackageExtensions.FromArray(Values); - /// Executes the BoolSource benchmark helper. - /// The library value. - /// The BoolSource result. + /// Creates a bool source over the shared boolean array from the selected library. + /// The library implementation to exercise. + /// An observable over . private static IObservable BoolSource(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.FromArray(BooleanValues) : PackageExtensions.FromArray(BooleanValues); - /// Executes the EnsureCompleted benchmark helper. - /// The task value. + /// Spin-waits for the operation, rethrowing cancellation or failure. + /// The operation to wait on. /// completed in the canceled state. /// completed in the faulted state. private static void EnsureCompleted(Task task) @@ -58,8 +58,8 @@ private static void EnsureCompleted(Task task) throw new InvalidOperationException("The benchmark task should complete successfully.", task.Exception); } - /// Executes the EnsureCompleted benchmark helper. - /// The task value. + /// Spin-waits for the operation, rethrowing cancellation or failure. + /// The operation to wait on. private static void EnsureCompleted(ValueTask task) { if (task.IsCompletedSuccessfully) @@ -70,18 +70,18 @@ private static void EnsureCompleted(ValueTask task) EnsureCompleted(task.AsTask()); } - /// Executes the GetCompletedResult benchmark helper. - /// The task value. - /// The GetCompletedResult result. + /// Waits for the operation to finish and returns the scenario checksum. + /// The operation to wait on. + /// The scenario checksum. private static int GetCompletedResult(Task task) { EnsureCompleted(task); return Value; } - /// Executes the GetCompletedResult benchmark helper. - /// The task value. - /// The GetCompletedResult result. + /// Waits for the operation to finish and returns the scenario checksum. + /// The operation to wait on. + /// The scenario checksum. private static int GetCompletedResult(ValueTask task) { if (!task.IsCompletedSuccessfully) @@ -92,10 +92,10 @@ private static int GetCompletedResult(ValueTask task) return Value; } - /// Executes the BoolSources benchmark helper. - /// The library value. - /// The value value. - /// The BoolSources result. + /// Yields two single-value bool sources from the selected library. + /// The library implementation to exercise. + /// The boolean each source emits. + /// Two observables that each emit . private static IEnumerable> BoolSources(ExtensionsLibrary library, bool value) { yield return library == ExtensionsLibrary.Primitives @@ -106,8 +106,8 @@ private static IEnumerable> BoolSources(ExtensionsLibrary libr : PackageObservables.Return(value); } - /// Executes the CompletedTasks benchmark helper. - /// The CompletedTasks result. + /// Yields pre-completed tasks carrying ascending values. + /// A lazy sequence of completed tasks. private static IEnumerable> CompletedTasks() { for (var i = 0; i < Count; i++) @@ -116,8 +116,8 @@ private static IEnumerable> CompletedTasks() } } - /// Executes the CreateValues benchmark helper. - /// The CreateValues result. + /// Builds the shared int payload as ascending values from zero. + /// An array of ascending values. private static int[] CreateValues() { var values = new int[Count]; @@ -129,9 +129,9 @@ private static int[] CreateValues() return values; } - /// Executes the DrainArray benchmark helper. - /// The source value. - /// The DrainArray result. + /// Subscribes to the source and sums the length of every emitted array. + /// The sequence to drain. + /// The summed array lengths. private static int DrainArray(IObservable source) { ArrayWitness observer = new(); @@ -139,9 +139,9 @@ private static int DrainArray(IObservable source) return observer.Total; } - /// Executes the DrainBool benchmark helper. - /// The source value. - /// The DrainBool result. + /// Subscribes to the source and folds the emitted booleans into a checksum. + /// The sequence to drain. + /// The true-value count plus the emission count. private static int DrainBool(IObservable source) { BoolSignalWitness observer = new(); @@ -149,9 +149,9 @@ private static int DrainBool(IObservable source) return observer.Total + observer.NextCount; } - /// Executes the DrainInt benchmark helper. - /// The source value. - /// The DrainInt result. + /// Subscribes to the source and folds the emitted integers into a checksum. + /// The sequence to drain. + /// The value total plus the emission count. private static int DrainInt(IObservable source) { IntSignalWitness observer = new(); @@ -159,9 +159,9 @@ private static int DrainInt(IObservable source) return observer.Total + observer.NextCount; } - /// Executes the DrainList benchmark helper. - /// The source value. - /// The DrainList result. + /// Subscribes to the source and sums the size of every emitted list. + /// The sequence to drain. + /// The summed list sizes. private static int DrainList(IObservable> source) { ListWitness observer = new(); @@ -169,9 +169,9 @@ private static int DrainList(IObservable> source) return observer.Total; } - /// Executes the DrainPackageUnit benchmark helper. - /// The source value. - /// The DrainPackageUnit result. + /// Subscribes to the source and counts the System.Reactive unit notifications. + /// The sequence to drain. + /// The emission count plus the completion count. private static int DrainPackageUnit(IObservable source) { CountingSignalWitness observer = new(); @@ -179,9 +179,9 @@ private static int DrainPackageUnit(IObservable source) return observer.Count + observer.CompletionCount; } - /// Executes the DrainPrimitiveUnit benchmark helper. - /// The source value. - /// The DrainPrimitiveUnit result. + /// Subscribes to the source and counts the primitives void notifications. + /// The sequence to drain. + /// The emission count plus the completion count. private static int DrainPrimitiveUnit(IObservable source) { CountingSignalWitness observer = new(); @@ -189,9 +189,9 @@ private static int DrainPrimitiveUnit(IObservable source) return observer.Count + observer.CompletionCount; } - /// Executes the DrainString benchmark helper. - /// The source value. - /// The DrainString result. + /// Subscribes to the source and folds emitted string lengths into a checksum, counting null as zero. + /// The sequence to drain. + /// The total string length plus the emission count. private static int DrainString(IObservable source) { NullableStringLengthWitness observer = new(); @@ -199,9 +199,9 @@ private static int DrainString(IObservable source) return observer.TotalLength + observer.ItemCount; } - /// Executes the DrainSyncTuple benchmark helper. - /// The source value. - /// The DrainSyncTuple result. + /// Subscribes to the source, summing each value and disposing its synchronization handle. + /// The sequence to drain. + /// The summed values. private static int DrainSyncTuple(IObservable<(int Value, IDisposable Sync)> source) { SyncTupleWitness observer = new(); @@ -209,29 +209,29 @@ private static int DrainSyncTuple(IObservable<(int Value, IDisposable Sync)> sou return observer.Total; } - /// Executes the Range benchmark helper. - /// The library value. - /// The Range result. + /// Creates the shared ascending range from the selected library. + /// The library implementation to exercise. + /// An observable over ascending values from zero. private static IObservable Range(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? Signal.Sequence(0, Count) : RxObservable.Range(0, Count); - /// Executes the ThrowInt benchmark helper. - /// The library value. - /// The ThrowInt result. + /// Creates an immediately failing int source from the selected library. + /// The library implementation to exercise. + /// An observable that fails with . private static IObservable ThrowInt(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? Signal.Fail(Boom) : RxObservable.Throw(Boom); - /// Executes the ThrowPackageUnit benchmark helper. - /// The ThrowPackageUnit result. + /// Creates a System.Reactive unit source that fails immediately. + /// An observable that fails with . [MethodImpl(MethodImplOptions.AggressiveInlining)] private static IObservable ThrowPackageUnit() => RxObservable.Throw(Boom); - /// Executes the ThrowPrimitiveUnit benchmark helper. - /// The ThrowPrimitiveUnit result. + /// Creates a primitives void source that fails immediately. + /// An observable that fails with . [MethodImpl(MethodImplOptions.AggressiveInlining)] private static IObservable ThrowPrimitiveUnit() => Signal.Fail(Boom); @@ -246,10 +246,10 @@ private static IObservable ThrowInt(ExtensionsLibrary library) => [System.Diagnostics.DebuggerDisplay("ExtensionScenario: Name = {_name}")] public sealed class ExtensionScenario(string name, Func run) { - /// Stores the scenario name. + /// The scenario name reported by BenchmarkDotNet. private readonly string _name = name; - /// Stores the scenario delegate. + /// The delegate invoked for each benchmark operation. private readonly Func _run = run; /// Runs the scenario delegate. @@ -261,10 +261,10 @@ public sealed class ExtensionScenario(string name, Func run) public override string ToString() => _name; } - /// Provides the ArrayWitness benchmark helper type. + /// Observer that accumulates the length of every emitted array. private sealed class ArrayWitness : IObserver { - /// Gets the Total benchmark helper value. + /// Gets the summed length of the arrays observed. public int Total { get; private set; } /// @@ -281,10 +281,10 @@ public void OnCompleted() } } - /// Provides the DummyResource benchmark helper type. + /// Disposable resource used by the resource-scoping scenarios. private sealed class DummyResource : IDisposable { - /// Gets or sets the TouchCount benchmark helper value. + /// Gets or sets the number of times the resource has been touched. private int TouchCount { get; set; } /// @@ -292,14 +292,14 @@ public void Dispose() { } - /// Executes the Touch benchmark helper. + /// Records one use of the resource. public void Touch() => TouchCount++; } - /// Provides the ListWitness benchmark helper type. + /// Observer that accumulates the size of every emitted list. private sealed class ListWitness : IObserver> { - /// Gets the Total benchmark helper value. + /// Gets the summed size of the lists observed. public int Total { get; private set; } /// @@ -316,13 +316,13 @@ public void OnCompleted() } } - /// Provides the NullableStringLengthWitness benchmark helper type. + /// Observer that counts emissions and accumulates string length, counting null as zero. private sealed class NullableStringLengthWitness : IObserver { - /// Gets the ItemCount benchmark helper value. + /// Gets the number of values observed. public int ItemCount { get; private set; } - /// Gets the TotalLength benchmark helper value. + /// Gets the summed length of the strings observed. public int TotalLength { get; private set; } /// @@ -343,10 +343,10 @@ public void OnCompleted() } } - /// Provides the PairWitness benchmark helper type. + /// Observer that sums both halves of every emitted pair. private sealed class PairWitness : IObserver<(int Previous, int Current)> { - /// Gets the Total benchmark helper value. + /// Gets the summed pair values. public int Total { get; private set; } /// @@ -363,13 +363,13 @@ public void OnCompleted() } } - /// Provides the PropertySource benchmark helper type. + /// Property holder that raises a change notification on every assignment. private sealed class PropertySource : INotifyPropertyChanged { - /// Occurs when the PropertyChanged benchmark helper event is raised. + /// Occurs when is assigned. public event PropertyChangedEventHandler? PropertyChanged; - /// Gets or sets the CurrentValue benchmark helper value. + /// Gets or sets the value whose assignment raises the change notification. public int CurrentValue { get; @@ -381,10 +381,10 @@ public int CurrentValue } } - /// Provides the R3BoolWitness benchmark helper type. + /// R3 observer that counts the true values observed. private sealed class R3BoolWitness : R3.Observer { - /// Gets the Total benchmark helper value. + /// Gets the number of true values observed. public int Total { get; private set; } /// @@ -409,11 +409,11 @@ protected override void OnCompletedCore(R3.Result result) } } - /// Provides the R3CountingWitness benchmark helper type. + /// R3 observer that counts the values observed. /// The observed value type. private sealed class R3CountingWitness : R3.Observer { - /// Gets the ItemCount benchmark helper value. + /// Gets the number of values observed. public int ItemCount { get; private set; } /// @@ -430,10 +430,10 @@ protected override void OnCompletedCore(R3.Result result) } } - /// Provides the SyncTupleWitness benchmark helper type. + /// Observer that sums each value and disposes its paired synchronization handle. private sealed class SyncTupleWitness : IObserver<(int Value, IDisposable Sync)> { - /// Gets the Total benchmark helper value. + /// Gets the summed values. public int Total { get; private set; } /// @@ -454,11 +454,11 @@ public void OnCompleted() } } - /// Provides the TupleWitness benchmark helper type. + /// Observer that counts each value and disposes its paired synchronization handle. /// The observed value type. private sealed class TupleWitness : IObserver<(T Value, IDisposable Sync)> { - /// Gets the ItemCount benchmark helper value. + /// Gets the number of values observed. public int ItemCount { get; private set; } /// diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.Supplemental.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.Supplemental.cs index 936d713e..8c1a2d26 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.Supplemental.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.Supplemental.cs @@ -21,9 +21,9 @@ namespace ReactiveUI.Primitives.Benchmarks; /// Benchmarks the complete synchronous ReactiveUI.Primitives.Extensions public helper surface. public partial class ReactiveExtensionsComparisonBenchmarks { - /// Executes the RunPartition benchmark helper. - /// The library value. - /// The RunPartition result. + /// Splits the array source into even and odd halves and drains both. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunPartition(ExtensionsLibrary library) { IntSignalWitness observer = new(); @@ -43,27 +43,27 @@ private static int RunPartition(ExtensionsLibrary library) return observer.Total; } - /// Executes the RunReplayLastOnSubscribe benchmark helper. - /// The library value. - /// The RunReplayLastOnSubscribe result. + /// Replays the last value of the array source to a late subscriber. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunReplayLastOnSubscribe(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.ReplayLastOnSubscribe(ArraySource(library), Fallback) : PackageExtensions.ReplayLastOnSubscribe(ArraySource(library), Fallback)); - /// Executes the RunRetryForeverWithDelay benchmark helper. - /// The library value. - /// The RunRetryForeverWithDelay result. + /// Retries the array source indefinitely with no delay between attempts. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunRetryForeverWithDelay(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.RetryForeverWithDelay(ArraySource(library), TimeSpan.Zero) : PackageExtensions.RetryForeverWithDelay(ArraySource(library), TimeSpan.Zero)); - /// Executes the RunRetryWithBackoff benchmark helper. - /// The library value. - /// The RunRetryWithBackoff result. + /// Retries the array source once with a zero-length backoff. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunRetryWithBackoff(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -82,44 +82,44 @@ private static int RunRetryWithBackoff(ExtensionsLibrary library) => TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunRetryWithDelay benchmark helper. - /// The library value. - /// The RunRetryWithDelay result. + /// Retries the array source once with a computed zero delay. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunRetryWithDelay(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.RetryWithDelay(ArraySource(library), 1, static _ => TimeSpan.Zero) : PackageExtensions.RetryWithDelay(ArraySource(library), 1, static _ => TimeSpan.Zero)); - /// Executes the RunRetryWithFixedDelay benchmark helper. - /// The library value. - /// The RunRetryWithFixedDelay result. + /// Retries the array source once with a fixed zero delay. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunRetryWithFixedDelay(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.RetryWithFixedDelay(ArraySource(library), 1, TimeSpan.Zero) : PackageExtensions.RetryWithFixedDelay(ArraySource(library), 1, TimeSpan.Zero)); - /// Executes the RunReturn benchmark helper. - /// The library value. - /// The RunReturn result. + /// Drains a single-value source. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunReturn(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesObservables.Return(Value) : PackageObservables.Return(Value)); - /// Executes the RunRunAll benchmark helper. - /// The library value. - /// The RunRunAll result. + /// Runs a single unit source to completion through RunAll. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunRunAll(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainPrimitiveUnit(PrimitivesExtensions.RunAll([PrimitivesObservables.Return(RxVoid.Default)])) : DrainPackageUnit(PackageExtensions.RunAll([PackageObservables.Return(RxUnit.Default)])); - /// Executes the RunSampleLatest benchmark helper. - /// The library value. - /// The RunSampleLatest result. + /// Samples the latest array value on each sampler notification. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSampleLatest(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -130,27 +130,27 @@ private static int RunSampleLatest(ExtensionsLibrary library) => ArraySource(library), PackageExtensions.SelectConstant(ArraySource(library), new object()))); - /// Executes the RunScanWithInitial benchmark helper. - /// The library value. - /// The RunScanWithInitial result. + /// Accumulates the array source from a seed value. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunScanWithInitial(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.ScanWithInitial(ArraySource(library), 0, static (acc, value) => acc + value) : PackageExtensions.ScanWithInitial(ArraySource(library), 0, static (acc, value) => acc + value)); - /// Executes the RunSchedule benchmark helper. - /// The library value. - /// The RunSchedule result. + /// Schedules a projection of one value on an immediate scheduler. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSchedule(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.Schedule(Value, Sequencer.Immediate, static value => value + 1) : PackageExtensions.Schedule(Value, ImmediateScheduler.Instance, static value => value + 1)); - /// Executes the RunScheduleSafe benchmark helper. - /// The library value. - /// The RunScheduleSafe result. + /// Schedules an error-isolated callback on an immediate scheduler. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunScheduleSafe(ExtensionsLibrary library) { var count = 0; @@ -160,18 +160,18 @@ private static int RunScheduleSafe(ExtensionsLibrary library) return count; } - /// Executes the RunSelectAsyncScenario benchmark helper. - /// The library value. - /// The RunSelectAsyncScenario result. + /// Projects each array value through a completed task. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSelectAsyncScenario(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.SelectAsync(ArraySource(library), static value => Task.FromResult(value + 1)) : PackageExtensions.SelectAsync(ArraySource(library), static value => Task.FromResult(value + 1))); - /// Executes the RunSelectAsyncConcurrent benchmark helper. - /// The library value. - /// The RunSelectAsyncConcurrent result. + /// Projects each array value through a completed task under a concurrency cap. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSelectAsyncConcurrent(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -184,9 +184,9 @@ private static int RunSelectAsyncConcurrent(ExtensionsLibrary library) => static value => Task.FromResult(value + 1), MaxConcurrency)); - /// Executes the RunSelectAsyncSequential benchmark helper. - /// The library value. - /// The RunSelectAsyncSequential result. + /// Projects each array value through a completed task, one at a time. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSelectAsyncSequential(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -197,27 +197,27 @@ private static int RunSelectAsyncSequential(ExtensionsLibrary library) => ArraySource(library), static value => Task.FromResult(value + 1))); - /// Executes the RunSelectConstant benchmark helper. - /// The library value. - /// The RunSelectConstant result. + /// Replaces every array value with a constant. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSelectConstant(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.SelectConstant(ArraySource(library), Value) : PackageExtensions.SelectConstant(ArraySource(library), Value)); - /// Executes the RunSelectLatestAsyncScenario benchmark helper. - /// The library value. - /// The RunSelectLatestAsyncScenario result. + /// Projects array values through a completed task, keeping only the latest. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSelectLatestAsyncScenario(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.SelectLatestAsync(ArraySource(library), static value => Task.FromResult(value + 1)) : PackageExtensions.SelectLatestAsync(ArraySource(library), static value => Task.FromResult(value + 1))); - /// Executes the RunSelectManyThen benchmark helper. - /// The library value. - /// The RunSelectManyThen result. + /// Chains two sequential projections from a single value. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSelectManyThen(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -230,35 +230,35 @@ private static int RunSelectManyThen(ExtensionsLibrary library) => static value => PackageObservables.Return(value + 1), static value => PackageObservables.Return(value + 1))); - /// Executes the RunShuffle benchmark helper. - /// The library value. - /// The RunShuffle result. + /// Shuffles a single emitted array. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunShuffle(ExtensionsLibrary library) => DrainArray(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.Shuffle(PrimitivesObservables.Return(Values)) : PackageExtensions.Shuffle(PackageObservables.Return(Values))); - /// Executes the RunSkipWhileNull benchmark helper. - /// The library value. - /// The RunSkipWhileNull result. + /// Skips the leading nulls of the string source. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSkipWhileNull(ExtensionsLibrary library) => DrainString(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.SkipWhileNull(PrimitivesExtensions.FromArray(SkipStrings)) : PackageExtensions.SkipWhileNull(PackageExtensions.FromArray(SkipStrings))); - /// Executes the RunStart benchmark helper. - /// The library value. - /// The RunStart result. + /// Starts a void action on an immediate scheduler and drains its unit notification. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunStart(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainPrimitiveUnit(PrimitivesExtensions.Start(static () => { }, Sequencer.Immediate)) : DrainPackageUnit(PackageExtensions.Start(static () => { }, ImmediateScheduler.Instance)); - /// Executes the RunSubscribeAndComplete benchmark helper. - /// The library value. - /// The RunSubscribeAndComplete result. + /// Subscribes to a single-value unit source and waits for completion. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunSubscribeAndComplete(ExtensionsLibrary library) { if (library == ExtensionsLibrary.Primitives) @@ -273,9 +273,9 @@ private static int RunSubscribeAndComplete(ExtensionsLibrary library) return 1; } - /// Executes the RunSubscribeAsyncScenario benchmark helper. - /// The library value. - /// The RunSubscribeAsyncScenario result. + /// Sums the array source through an asynchronous subscription callback. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunSubscribeAsyncScenario(ExtensionsLibrary library) { var total = 0; @@ -293,9 +293,9 @@ private static int RunSubscribeAsyncScenario(ExtensionsLibrary library) return total; } - /// Executes the RunSubscribeGetError benchmark helper. - /// The library value. - /// The RunSubscribeGetError result. + /// Subscribes to a failing source and reports whether an error surfaced. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunSubscribeGetError(ExtensionsLibrary library) { var error = library == ExtensionsLibrary.Primitives @@ -305,17 +305,17 @@ private static int RunSubscribeGetError(ExtensionsLibrary library) return error is null ? 0 : 1; } - /// Executes the RunSubscribeGetValue benchmark helper. - /// The library value. - /// The RunSubscribeGetValue result. + /// Subscribes to the array source and reads the single returned value. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunSubscribeGetValue(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? PrimitivesSubscriptionExtensions.SubscribeGetValue(ArraySource(library)) : PackageSubscriptionExtensions.SubscribeGetValue(ArraySource(library)); - /// Executes the RunSubscribeSynchronous benchmark helper. - /// The library value. - /// The RunSubscribeSynchronous result. + /// Sums the array source through a synchronous subscription callback. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunSubscribeSynchronous(ExtensionsLibrary library) { var total = 0; @@ -333,18 +333,18 @@ private static int RunSubscribeSynchronous(ExtensionsLibrary library) return total; } - /// Executes the RunSwitchIfEmpty benchmark helper. - /// The library value. - /// The RunSwitchIfEmpty result. + /// Substitutes a single-value source when the source completes empty. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSwitchIfEmpty(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.SwitchIfEmpty(Signal.None(), PrimitivesObservables.Return(Value)) : PackageExtensions.SwitchIfEmpty(RxObservable.Empty(), PackageObservables.Return(Value))); - /// Executes the RunSyncTimer benchmark helper. - /// The library value. - /// The RunSyncTimer result. + /// Advances virtual time once over a wall-clock timer. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunSyncTimer(ExtensionsLibrary library) { if (library == ExtensionsLibrary.Primitives) @@ -363,63 +363,63 @@ private static int RunSyncTimer(ExtensionsLibrary library) return packageObserver.Count + packageObserver.CompletionCount; } - /// Executes the RunSynchronizeAsyncScenario benchmark helper. - /// The library value. - /// The RunSynchronizeAsyncScenario result. + /// Pairs each array value with an asynchronously acquired synchronization handle. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSynchronizeAsyncScenario(ExtensionsLibrary library) => DrainSyncTuple(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.SynchronizeAsync(ArraySource(library)) : PackageExtensions.SynchronizeAsync(ArraySource(library))); - /// Executes the RunSynchronizeSynchronous benchmark helper. - /// The library value. - /// The RunSynchronizeSynchronous result. + /// Pairs each array value with a synchronously acquired synchronization handle. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunSynchronizeSynchronous(ExtensionsLibrary library) => DrainSyncTuple(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.SynchronizeSynchronous(ArraySource(library)) : PackageExtensions.SynchronizeSynchronous(ArraySource(library))); - /// Executes the RunTakeUntil benchmark helper. - /// The library value. - /// The RunTakeUntil result. + /// Truncates the array source at the match threshold. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunTakeUntil(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.TakeUntil(ArraySource(library), static value => value == Match) : PackageExtensions.TakeUntil(ArraySource(library), static value => value == Match)); - /// Executes the RunThrottleDistinct benchmark helper. - /// The library value. - /// The RunThrottleDistinct result. + /// Throttles the array source, suppressing repeats inside the window. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunThrottleDistinct(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.ThrottleDistinct(ArraySource(library), TimeSpan.Zero, Sequencer.Immediate) : PackageExtensions.ThrottleDistinct(ArraySource(library), TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunThrottleFirst benchmark helper. - /// The library value. - /// The RunThrottleFirst result. + /// Throttles the array source, keeping the leading value of each window. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunThrottleFirst(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.ThrottleFirst(ArraySource(library), TimeSpan.Zero, Sequencer.Immediate) : PackageExtensions.ThrottleFirst(ArraySource(library), TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunThrottleOnScheduler benchmark helper. - /// The library value. - /// The RunThrottleOnScheduler result. + /// Throttles the array source on an immediate scheduler. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunThrottleOnScheduler(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.ThrottleOnScheduler(ArraySource(library), TimeSpan.Zero, Sequencer.Immediate) : PackageExtensions.ThrottleOnScheduler(ArraySource(library), TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunThrottleUntilTrue benchmark helper. - /// The library value. - /// The RunThrottleUntilTrue result. + /// Throttles the array source until a value clears the match threshold. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunThrottleUntilTrue(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -432,25 +432,25 @@ private static int RunThrottleUntilTrue(ExtensionsLibrary library) => TimeSpan.Zero, static value => value >= Match)); - /// Executes the RunToHotTask benchmark helper. - /// The library value. - /// The RunToHotTask result. + /// Converts a single-value source to an eagerly started task and waits for it. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunToHotTask(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? GetCompletedResult(PrimitivesExtensions.ToHotTask(PrimitivesObservables.Return(Value))) : GetCompletedResult(PackageExtensions.ToHotTask(PackageObservables.Return(Value))); - /// Executes the RunToHotValueTask benchmark helper. - /// The library value. - /// The RunToHotValueTask result. + /// Converts a single-value source to an eagerly started value task and waits for it. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunToHotValueTask(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? GetCompletedResult(PrimitivesExtensions.ToHotValueTask(PrimitivesObservables.Return(Value))) : GetCompletedResult(PackageExtensions.ToHotValueTask(PackageObservables.Return(Value))); - /// Executes the RunToPropertyObservable benchmark helper. - /// The library value. - /// The RunToPropertyObservable result. + /// Bridges a property-changed notification into an observable. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunToPropertyObservable(ExtensionsLibrary library) { PropertySource source = new(); @@ -464,9 +464,9 @@ private static int RunToPropertyObservable(ExtensionsLibrary library) return observer.Total; } - /// Executes the RunToReadOnlyBehavior benchmark helper. - /// The library value. - /// The RunToReadOnlyBehavior result. + /// Pushes a value through a read-only behavior and its paired sink. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunToReadOnlyBehavior(ExtensionsLibrary library) { IntSignalWitness observer = new(); @@ -486,9 +486,9 @@ private static int RunToReadOnlyBehavior(ExtensionsLibrary library) return observer.Total; } - /// Executes the RunTrySelect benchmark helper. - /// The library value. - /// The RunTrySelect result. + /// Projects even array values to strings and drops the rest. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunTrySelect(ExtensionsLibrary library) => DrainString(library == ExtensionsLibrary.Primitives @@ -499,17 +499,17 @@ private static int RunTrySelect(ExtensionsLibrary library) => ArraySource(library), static value => value % EvenDivisor == 0 ? value.ToString(CultureInfo.InvariantCulture) : null)); - /// Executes the RunUsing benchmark helper. - /// The library value. - /// The RunUsing result. + /// Scopes a disposable resource around a unit source. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunUsing(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainPrimitiveUnit(PrimitivesExtensions.Using(new DummyResource(), static resource => resource.Touch())) : DrainPackageUnit(PackageExtensions.Using(new DummyResource(), static resource => resource.Touch())); - /// Executes the RunWaitForCompletion benchmark helper. - /// The library value. - /// The RunWaitForCompletion result. + /// Blocks on a single-value unit source until it completes. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunWaitForCompletion(ExtensionsLibrary library) { if (library == ExtensionsLibrary.Primitives) @@ -526,9 +526,9 @@ private static int RunWaitForCompletion(ExtensionsLibrary library) return 1; } - /// Executes the RunWaitForError benchmark helper. - /// The library value. - /// The RunWaitForError result. + /// Blocks on a failing source and reports whether an error surfaced. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunWaitForError(ExtensionsLibrary library) { var error = library == ExtensionsLibrary.Primitives @@ -538,44 +538,44 @@ private static int RunWaitForError(ExtensionsLibrary library) return error is null ? 0 : 1; } - /// Executes the RunWaitForValue benchmark helper. - /// The library value. - /// The RunWaitForValue result. + /// Blocks on the array source until its first value arrives. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunWaitForValue(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? PrimitivesSubscriptionExtensions.WaitForValue(ArraySource(library), WaitTimeout) : PackageSubscriptionExtensions.WaitForValue(ArraySource(library), WaitTimeout); - /// Executes the RunWaitUntil benchmark helper. - /// The library value. - /// The RunWaitUntil result. + /// Waits for the first array value that matches the threshold. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunWaitUntil(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.WaitUntil(ArraySource(library), static value => value == Match) : PackageExtensions.WaitUntil(ArraySource(library), static value => value == Match)); - /// Executes the RunWhereFalse benchmark helper. - /// The library value. - /// The RunWhereFalse result. + /// Keeps only the false values of the boolean source. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunWhereFalse(ExtensionsLibrary library) => DrainBool(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.WhereFalse(BoolSource(library)) : PackageExtensions.WhereFalse(BoolSource(library))); - /// Executes the RunWhereIsNotNull benchmark helper. - /// The library value. - /// The RunWhereIsNotNull result. + /// Keeps only the non-null values of the string source. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunWhereIsNotNull(ExtensionsLibrary library) => DrainString(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.WhereIsNotNull(PrimitivesExtensions.FromArray(NullableStrings)) : PackageExtensions.WhereIsNotNull(PackageExtensions.FromArray(NullableStrings))); - /// Executes the RunWhereSelect benchmark helper. - /// The library value. - /// The RunWhereSelect result. + /// Filters the array source to even values and scales them. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunWhereSelect(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -588,18 +588,18 @@ private static int RunWhereSelect(ExtensionsLibrary library) => static value => (value & 1) == 0, static value => value * ResultMultiplier)); - /// Executes the RunWhereTrue benchmark helper. - /// The library value. - /// The RunWhereTrue result. + /// Keeps only the true values of the boolean source. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunWhereTrue(ExtensionsLibrary library) => DrainBool(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.WhereTrue(BoolSource(library)) : PackageExtensions.WhereTrue(BoolSource(library))); - /// Executes the RunWhile benchmark helper. - /// The library value. - /// The RunWhile result. + /// Repeats a unit action while a counter remains positive. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunWhile(ExtensionsLibrary library) { var remaining = Count; @@ -619,9 +619,9 @@ bool ShouldContinue() : DrainPackageUnit(PackageExtensions.While(ShouldContinue, RecordIteration)) + total; } - /// Executes the RunWithLimitedConcurrency benchmark helper. - /// The library value. - /// The RunWithLimitedConcurrency result. + /// Awaits pre-completed tasks under a concurrency cap. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunWithLimitedConcurrency(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.cs index 9ab16ea9..fcca7abc 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveExtensionsComparisonBenchmarks.Library.cs @@ -22,25 +22,25 @@ namespace ReactiveUI.Primitives.Benchmarks; /// Benchmarks the complete synchronous ReactiveUI.Primitives.Extensions public helper surface. public partial class ReactiveExtensionsComparisonBenchmarks { - /// Executes the RunAsSignal benchmark helper. - /// The library value. - /// The RunAsSignal result. + /// Projects the selected library's range to unit values through AsSignal. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunAsSignal(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainPrimitiveUnit(PrimitivesExtensions.AsSignal(Range(library))) : DrainPackageUnit(PackageExtensions.AsSignal(Range(library))); - /// Executes the RunBufferUntil benchmark helper. - /// The library value. - /// The RunBufferUntil result. + /// Buffers the character source between bracket delimiters. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunBufferUntil(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainString(PrimitivesExtensions.BufferUntil(PrimitivesExtensions.FromArray(BufferCharacters), '[', ']')) : DrainString(PackageExtensions.BufferUntil(PackageExtensions.FromArray(BufferCharacters), '[', ']')); - /// Executes the RunBufferUntilIdle benchmark helper. - /// The library value. - /// The RunBufferUntilIdle result. + /// Buffers the array source until it falls idle, on an immediate scheduler. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunBufferUntilIdle(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainList(PrimitivesExtensions.BufferUntilIdle(ArraySource(library), TimeSpan.Zero, Sequencer.Immediate)) @@ -49,9 +49,9 @@ private static int RunBufferUntilIdle(ExtensionsLibrary library) => TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunBufferUntilInactive benchmark helper. - /// The library value. - /// The RunBufferUntilInactive result. + /// Buffers the array source until it falls inactive, on an immediate scheduler. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunBufferUntilInactive(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainList( @@ -61,9 +61,9 @@ private static int RunBufferUntilInactive(ExtensionsLibrary library) => TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunCatchAndReturn benchmark helper. - /// The library value. - /// The RunCatchAndReturn result. + /// Substitutes a computed fallback for a typed failure through CatchAndReturn. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunCatchAndReturn(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -74,62 +74,62 @@ private static int RunCatchAndReturn(ExtensionsLibrary library) => ThrowInt(library), static _ => Fallback)); - /// Executes the RunCatchIgnore benchmark helper. - /// The library value. - /// The RunCatchIgnore result. + /// Swallows a typed failure through CatchIgnore. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunCatchIgnore(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.CatchIgnore(ThrowInt(library), static _ => { }) : PackageExtensions.CatchIgnore(ThrowInt(library), static _ => { })); - /// Executes the RunCatchReturn benchmark helper. - /// The library value. - /// The RunCatchReturn result. + /// Substitutes a constant fallback for a failure through CatchReturn. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunCatchReturn(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.CatchReturn(ThrowInt(library), Fallback) : PackageExtensions.CatchReturn(ThrowInt(library), Fallback)); - /// Executes the RunCatchReturnUnit benchmark helper. - /// The library value. - /// The RunCatchReturnUnit result. + /// Substitutes a unit value for a failing unit source. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunCatchReturnUnit(ExtensionsLibrary library) => library == ExtensionsLibrary.Primitives ? DrainPrimitiveUnit(PrimitivesExtensions.CatchReturnUnit(ThrowPrimitiveUnit())) : DrainPackageUnit(PackageExtensions.CatchReturnUnit(ThrowPackageUnit())); - /// Executes the RunCombineLatestValuesAreAllFalse benchmark helper. - /// The library value. - /// The RunCombineLatestValuesAreAllFalse result. + /// Combines two false sources and tests that every value is false. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunCombineLatestValuesAreAllFalse(ExtensionsLibrary library) => DrainBool(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.CombineLatestValuesAreAllFalse(BoolSources(library, false)) : PackageExtensions.CombineLatestValuesAreAllFalse(BoolSources(library, false))); - /// Executes the RunCombineLatestValuesAreAllTrue benchmark helper. - /// The library value. - /// The RunCombineLatestValuesAreAllTrue result. + /// Combines two true sources and tests that every value is true. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunCombineLatestValuesAreAllTrue(ExtensionsLibrary library) => DrainBool(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.CombineLatestValuesAreAllTrue(BoolSources(library, true)) : PackageExtensions.CombineLatestValuesAreAllTrue(BoolSources(library, true))); - /// Executes the RunConflate benchmark helper. - /// The library value. - /// The RunConflate result. + /// Conflates the array source over a zero window on an immediate scheduler. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunConflate(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.Conflate(ArraySource(library), TimeSpan.Zero, Sequencer.Immediate) : PackageExtensions.Conflate(ArraySource(library), TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunContinuationDispose benchmark helper. - /// The library value. - /// The RunContinuationDispose result. + /// Constructs and disposes a continuation, reading its completed phase count. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunContinuationDispose(ExtensionsLibrary library) { if (library == ExtensionsLibrary.Primitives) @@ -142,9 +142,9 @@ private static int RunContinuationDispose(ExtensionsLibrary library) return (int)packageContinuation.CompletedPhases; } - /// Executes the RunContinuationLock benchmark helper. - /// The library value. - /// The RunContinuationLock result. + /// Acquires a continuation lock and observes the task-based handle. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunContinuationLock(ExtensionsLibrary library) { TupleWitness observer = new(); @@ -162,9 +162,9 @@ private static int RunContinuationLock(ExtensionsLibrary library) return observer.ItemCount; } - /// Executes the RunContinuationLockValueTask benchmark helper. - /// The library value. - /// The RunContinuationLockValueTask result. + /// Acquires a continuation lock and observes the value-task handle. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunContinuationLockValueTask(ExtensionsLibrary library) { TupleWitness observer = new(); @@ -182,18 +182,18 @@ private static int RunContinuationLockValueTask(ExtensionsLibrary library) return observer.ItemCount; } - /// Executes the RunDebounceImmediate benchmark helper. - /// The library value. - /// The RunDebounceImmediate result. + /// Debounces the array source, emitting the leading value of each window. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunDebounceImmediate(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.DebounceImmediate(ArraySource(library), TimeSpan.Zero, Sequencer.Immediate) : PackageExtensions.DebounceImmediate(ArraySource(library), TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunDebounceUntil benchmark helper. - /// The library value. - /// The RunDebounceUntil result. + /// Debounces the array source until a value clears the match threshold. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunDebounceUntil(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -208,9 +208,9 @@ private static int RunDebounceUntil(ExtensionsLibrary library) => static value => value >= Match, ImmediateScheduler.Instance)); - /// Executes the RunDetectStale benchmark helper. - /// The library value. - /// The RunDetectStale result. + /// Advances virtual time over a silent source to emit a stale marker. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunDetectStale(ExtensionsLibrary library) { if (library == ExtensionsLibrary.Primitives) @@ -231,9 +231,9 @@ private static int RunDetectStale(ExtensionsLibrary library) return packageObserver.Count + packageObserver.CompletionCount; } - /// Executes the RunDoOnDispose benchmark helper. - /// The library value. - /// The RunDoOnDispose result. + /// Counts the dispose callback raised when the subscription is released. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunDoOnDispose(ExtensionsLibrary library) { var count = 0; @@ -245,9 +245,9 @@ private static int RunDoOnDispose(ExtensionsLibrary library) return count; } - /// Executes the RunDoOnSubscribe benchmark helper. - /// The library value. - /// The RunDoOnSubscribe result. + /// Counts the subscribe callback raised when the source is subscribed. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunDoOnSubscribe(ExtensionsLibrary library) { var count = 0; @@ -257,18 +257,18 @@ private static int RunDoOnSubscribe(ExtensionsLibrary library) return total + count; } - /// Executes the RunDropIfBusy benchmark helper. - /// The library value. - /// The RunDropIfBusy result. + /// Drops values from the array source while the handler is busy. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunDropIfBusy(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.DropIfBusy(ArraySource(library), static _ => default) : PackageExtensions.DropIfBusy(ArraySource(library), static _ => default)); - /// Executes the RunFastForEach benchmark helper. - /// The library value. - /// The RunFastForEach result. + /// Pushes the shared array into an observer through FastForEach. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunFastForEach(ExtensionsLibrary library) { IntSignalWitness observer = new(); @@ -284,18 +284,18 @@ private static int RunFastForEach(ExtensionsLibrary library) return observer.Total; } - /// Executes the RunFilter benchmark helper. - /// The library value. - /// The RunFilter result. + /// Filters the string source by the even-digit regex. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunFilter(ExtensionsLibrary library) => DrainString(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.Filter(PrimitivesExtensions.FromArray(StringValues), EvenRegex()) : PackageExtensions.Filter(PackageExtensions.FromArray(StringValues), EvenRegex())); - /// Executes the RunFirstMatchFromCandidates benchmark helper. - /// The library value. - /// The RunFirstMatchFromCandidates result. + /// Probes candidate values and takes the first that clears the match threshold. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunFirstMatchFromCandidates(ExtensionsLibrary library) { var candidates = Values; @@ -314,9 +314,9 @@ private static int RunFirstMatchFromCandidates(ExtensionsLibrary library) Fallback)); } - /// Executes the RunForEach benchmark helper. - /// The library value. - /// The RunForEach result. + /// Flattens a single batch of values through ForEach. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunForEach(ExtensionsLibrary library) { int[][] batches = [Values]; @@ -325,15 +325,15 @@ private static int RunForEach(ExtensionsLibrary library) : PackageExtensions.ForEach(PackageExtensions.FromArray>(batches), null)); } - /// Executes the RunFromArray benchmark helper. - /// The library) value. - /// The RunFromArray result. + /// Drains the shared int array through the library's FromArray. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunFromArray(ExtensionsLibrary library) => DrainInt(ArraySource(library)); - /// Executes the RunGetMax benchmark helper. - /// The library value. - /// The RunGetMax result. + /// Combines two scalar sources into their maximum. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunGetMax(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -344,9 +344,9 @@ private static int RunGetMax(ExtensionsLibrary library) => PackageObservables.Return(FirstValue), PackageObservables.Return(SecondValue))); - /// Executes the RunGetMin benchmark helper. - /// The library value. - /// The RunGetMin result. + /// Combines two scalar sources into their minimum. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunGetMin(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -357,9 +357,9 @@ private static int RunGetMin(ExtensionsLibrary library) => PackageObservables.Return(FirstValue), PackageObservables.Return(SecondValue))); - /// Executes the RunHeartbeat benchmark helper. - /// The library value. - /// The RunHeartbeat result. + /// Advances virtual time over a silent source to emit a heartbeat. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunHeartbeat(ExtensionsLibrary library) { if (library == ExtensionsLibrary.Primitives) @@ -380,18 +380,18 @@ private static int RunHeartbeat(ExtensionsLibrary library) return packageObserver.Count + packageObserver.CompletionCount; } - /// Executes the RunLatestOrDefault benchmark helper. - /// The library value. - /// The RunLatestOrDefault result. + /// Reads the latest value of the array source, falling back when it has none. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunLatestOrDefault(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.LatestOrDefault(ArraySource(library), Fallback) : PackageExtensions.LatestOrDefault(ArraySource(library), Fallback)); - /// Executes the RunLogErrors benchmark helper. - /// The library value. - /// The RunLogErrors result. + /// Wraps the array source in the error-logging operator and drains it. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunLogErrors(ExtensionsLibrary library) { var errors = 0; @@ -401,36 +401,36 @@ private static int RunLogErrors(ExtensionsLibrary library) return total + errors; } - /// Executes the RunNot benchmark helper. - /// The library value. - /// The RunNot result. + /// Negates each boolean emitted by the boolean source. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunNot(ExtensionsLibrary library) => DrainBool(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.Not(BoolSource(library)) : PackageExtensions.Not(BoolSource(library))); - /// Executes the RunObserveOnIf benchmark helper. - /// The library value. - /// The RunObserveOnIf result. + /// Conditionally reschedules the array source onto an immediate scheduler. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunObserveOnIf(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.ObserveOnIf(ArraySource(library), true, Sequencer.Immediate) : PackageExtensions.ObserveOnIf(ArraySource(library), true, ImmediateScheduler.Instance)); - /// Executes the RunObserveOnSafe benchmark helper. - /// The library value. - /// The RunObserveOnSafe result. + /// Reschedules the array source onto an immediate scheduler with error isolation. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunObserveOnSafe(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives ? PrimitivesExtensions.ObserveOnSafe(ArraySource(library), Sequencer.Immediate) : PackageExtensions.ObserveOnSafe(ArraySource(library), ImmediateScheduler.Instance)); - /// Executes the RunOnErrorRetry benchmark helper. - /// The library value. - /// The RunOnErrorRetry result. + /// Retries the array source once on a typed failure with no delay. + /// The library implementation to exercise. + /// The scenario checksum. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RunOnErrorRetry(ExtensionsLibrary library) => DrainInt(library == ExtensionsLibrary.Primitives @@ -447,9 +447,9 @@ private static int RunOnErrorRetry(ExtensionsLibrary library) => TimeSpan.Zero, ImmediateScheduler.Instance)); - /// Executes the RunOnNext benchmark helper. - /// The library value. - /// The RunOnNext result. + /// Pushes the shared array into an observer one value at a time. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunOnNext(ExtensionsLibrary library) { IntSignalWitness observer = new(); @@ -465,9 +465,9 @@ private static int RunOnNext(ExtensionsLibrary library) return observer.Total; } - /// Executes the RunPairwise benchmark helper. - /// The library value. - /// The RunPairwise result. + /// Pairs each value of the array source with its predecessor. + /// The library implementation to exercise. + /// The scenario checksum. private static int RunPairwise(ExtensionsLibrary library) { PairWitness observer = new(); diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs index e89003d2..837bff4d 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs @@ -10,8 +10,7 @@ namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks; -/// What the observable-event generator itself costs on a build and on a keystroke. -/// Measures generator execution for cold, unchanged, unrelated-edit, and event-edit inputs, excluding compilation updates. +/// Generator execution for cold, unchanged, unrelated-edit, and event-edit inputs, excluding compilation updates. [System.Diagnostics.DebuggerDisplay("EventGeneratorDriverBenchmarks: {Size}")] [SimpleJob(warmupCount: 5, iterationCount: 15)] [EventPipeProfiler(EventPipeProfile.CpuSampling)] diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs index 860acf58..e17f17a8 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs @@ -14,7 +14,6 @@ namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks; /// Allocation baselines for the generator's cold and incremental runs, with a GC-verbose trace naming the frames /// the allocations come from. Opt in with --filter "*GcProfile*". /// -/// Profiles allocation call stacks for the largest event corpus. [ShortRunJob] [EventPipeProfiler(EventPipeProfile.GcVerbose)] [System.Diagnostics.DebuggerDisplay("EventGeneratorGcProfileBenchmarks: {nameof(EventGeneratorGcProfileBenchmarks),nq}")] diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs index 9bc12082..52ec1eed 100644 --- a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs +++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs @@ -36,14 +36,14 @@ internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateCo /// Runs the generator with unchanged inputs to measure cache reuse. /// The corpus size. - /// The unchanged compilation and a driver that has already generated once. + /// The unchanged compilation and the primed driver. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUnchangedState(CorpusSize size) => RunOnce(size); /// Runs the generator after editing a file unrelated to event activation. /// The corpus size. - /// The edited compilation and a driver that has already generated once. + /// The edited compilation and the primed driver. internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUnrelatedEditState(CorpusSize size) { var primed = RunOnce(size); @@ -54,7 +54,7 @@ internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUn /// Runs the generator after changing one host's event declarations. /// The corpus size. - /// The edited compilation and a driver that has already generated once. + /// The edited compilation and the primed driver. internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateEventEditState(CorpusSize size) { var primed = RunOnce(size); diff --git a/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs b/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs index 8a49dcb4..9e4f2578 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/DisposableBagTests.cs @@ -108,7 +108,7 @@ public async Task WhenAddAfterDispose_ThenSuppliedDisposableDisposedImmediately( await Assert.That(late.DisposeCount).IsEqualTo(1); } - /// Tracking disposable used to count dispose invocations. + /// Tracking disposable that counts its dispose invocations. private sealed class CountedDisposable : IDisposable { /// Gets the number of times has been invoked. diff --git a/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs b/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs index 21593a7d..5f4dccdf 100644 --- a/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs +++ b/src/tests/ReactiveUI.Disposables.Tests/DisposableSlotHelperTests.cs @@ -134,7 +134,7 @@ public async Task WhenAssignmentCompletesBeforeDisposal_ThenRetainsStoredValue() await Assert.That(incoming.DisposeCount).IsEqualTo(0); } - /// Disposable used to verify dispose counts. + /// Disposable that records its dispose count. private sealed class CountingDisposable : IDisposable { /// Gets the number of times has been invoked. diff --git a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/ReactiveAsyncTests.cs b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/ReactiveAsyncTests.cs index 5f7c0cdc..89a50c7d 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/ReactiveAsyncTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/ReactiveAsyncTests.cs @@ -7,13 +7,13 @@ namespace ReactiveUI.Primitives.Async.Reactive.Tests; -/// Smoke tests confirming the Reactive async leaf's recompiled shared source behaves correctly. +/// Smoke tests for the shared async source as recompiled into the Reactive async leaf. public class ReactiveAsyncTests { /// Sentinel value used by the tests. private const int Sentinel = 42; - /// The Core engine compiled into the leaf still emits values through the async subscribe path. + /// The Core engine compiled into the leaf emits values through the async subscribe path. /// A representing the asynchronous test operation. [Test] public async Task Return_EmitsValueAndCompletes() diff --git a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs index ea249eb9..75d08830 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerSchedulingTests.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Async.Reactive.Tests; -/// Tests scheduling overloads. Fully qualified scheduler names avoid ambiguous System.Reactive extension methods. +/// Tests the scheduling overloads the seam adds to a System.Reactive scheduler. public class SequencerSchedulingTests { /// State threaded through the closure-free stateful overloads. @@ -15,11 +15,12 @@ public class SequencerSchedulingTests /// How many times the recursive overload reschedules itself before it stops. private const int ExpectedRecursiveRuns = 3; + // Fully qualified: an unqualified IScheduler makes the seam's overloads ambiguous with System.Reactive's own. /// The scheduler the seam forwards to, typed as the interface the seam extends. private static readonly System.Reactive.Concurrency.IScheduler InlineScheduler = System.Reactive.Concurrency.ImmediateScheduler.Instance; - /// A due time that has already passed, so absolute scheduling runs inline. + /// A due time in the past, so absolute scheduling runs inline. private static readonly DateTimeOffset ElapsedDueTime = DateTimeOffset.UnixEpoch; /// Verifies the plain action overload runs the action on the scheduler. diff --git a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerTests.cs b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerTests.cs index a5ebfc8b..db937fd1 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Reactive.Tests/SequencerTests.cs @@ -6,7 +6,7 @@ namespace ReactiveUI.Primitives.Async.Reactive.Tests; -/// Verifies sequencers. +/// Verifies each built-in sequencer maps onto its System.Reactive scheduler and that interval normalization forwards. public class SequencerTests { /// A negative interval, which normalization clamps to zero. diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs index 848021d7..185a5880 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CombineLatestOperatorTests.EnumerableRest.cs @@ -8,13 +8,10 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// Tests for CombineLatestOperatorTests. +/// Tests the enumerable CombineLatest overload's disposal and completion guards. public partial class CombineLatestOperatorTests { - /// - /// Tests CombineLatest enumerable returns early from SubscribeAsync when disposed during subscribe, - /// exercising the early return path in CombineLatestEnumerable. - /// + /// Verifies that emitting after an immediate dispose completes without error. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableDisposedDuringSubscribe_ThenReturnsEarly() @@ -26,17 +23,12 @@ public async Task WhenCombineLatestEnumerableDisposedDuringSubscribe_ThenReturns static (_, _) => default, null); - // Dispose immediately, before any values await sub.DisposeAsync(); - // Emit after dispose - should be ignored await signal.OnNextAsync(1, CancellationToken.None); } - /// - /// Tests CombineLatest enumerable OnNextAsync returns early when disposed, - /// exercising the OnNextAsync disposed guard in CombineLatestEnumerable. - /// + /// Verifies that values pushed by either source after disposal are not forwarded. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableOnNextAfterDispose_ThenIgnored() @@ -58,17 +50,13 @@ public async Task WhenCombineLatestEnumerableOnNextAfterDispose_ThenIgnored() await sub.DisposeAsync(); - // These should be ignored await signal1.OnNextAsync(1, CancellationToken.None); await signal2.OnNextAsync(SecondSignalValue, CancellationToken.None); await Assert.That(items).IsEmpty(); } - /// - /// Tests CombineLatest enumerable OnErrorResumeAsync returns early when disposed, - /// exercising the OnErrorResumeAsync disposed guard in CombineLatestEnumerable. - /// + /// Verifies that an error resumed after disposal is not forwarded. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableOnErrorResumeAfterDispose_ThenIgnored() @@ -88,16 +76,12 @@ public async Task WhenCombineLatestEnumerableOnErrorResumeAfterDispose_ThenIgnor await sub.DisposeAsync(); - // Error after dispose should be ignored await signal.OnErrorResumeAsync(new InvalidOperationException("err"), CancellationToken.None); await Assert.That(errors).IsEmpty(); } - /// - /// Tests CombineLatest enumerable OnCompletedAsync returns early when already completed for same index, - /// exercising the already-completed guard in CombineLatestEnumerable. - /// + /// Verifies that a source completing after disposal forwards no completion. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableAlreadyDisposed_ThenOnCompletedIgnored() @@ -117,20 +101,14 @@ public async Task WhenCombineLatestEnumerableAlreadyDisposed_ThenOnCompletedIgno return default; }); - // Dispose first await sub.DisposeAsync(); - // Complete after dispose - should be ignored await signal1.OnCompletedAsync(Result.Success); - // No extra completion should have been forwarded since we disposed await Assert.That(completion).IsNull(); } - /// - /// Tests CombineLatest enumerable completes when a source completes without emitting a value, - /// exercising the shouldComplete path in CombineLatestEnumerable. - /// + /// Verifies that an empty source completes the combined sequence during subscription. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableSourceCompletesWithoutValue_ThenCompletes() @@ -156,10 +134,7 @@ public async Task WhenCombineLatestEnumerableSourceCompletesWithoutValue_ThenCom await Assert.That(completion!.Value.IsSuccess).IsTrue(); } - /// - /// Verifies that CombineLatestEnumerable OnNextAsync returns early when disposed. - /// Uses the blocking-OnCompletedAsync technique to keep the gate alive while _disposed is set. - /// + /// Verifies that a value arriving while the subscription tears down is dropped. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableOnNextAfterDispose_ThenReturnsEarly() @@ -185,15 +160,13 @@ public async Task WhenCombineLatestEnumerableOnNextAfterDispose_ThenReturnsEarly await allowCompletion.Task; }); - // Set up initial values await src1.EmitNext(1); await src2.EmitNext(Source1Value); - // Trigger failure on src1 → FinishAsync → _disposed=1 → blocks on OnCompletedAsync + // Blocking inside the completion handler parks the subscription mid-teardown. var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; - // _disposed is 1, gate still alive → OnNextAsync should hit the guard await src2.EmitNext(SentinelValue); await Assert.That(items).Count().IsEqualTo(1); @@ -202,7 +175,7 @@ public async Task WhenCombineLatestEnumerableOnNextAfterDispose_ThenReturnsEarly await failTask; } - /// Verifies that CombineLatestEnumerable OnErrorResumeAsync returns early when disposed. + /// Verifies that an error arriving while the subscription tears down is dropped. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableOnErrorResumeAfterDispose_ThenReturnsEarly() @@ -231,10 +204,10 @@ public async Task WhenCombineLatestEnumerableOnErrorResumeAfterDispose_ThenRetur await src1.EmitNext(1); await src2.EmitNext(Source1Value); + // Blocking inside the completion handler parks the subscription mid-teardown. var failTask = src1.Complete(Result.Failure(new InvalidOperationException("test"))); await completionBlocked.Task; - // _disposed is 1, gate still alive → OnErrorResumeAsync should hit the guard await src2.EmitError(new InvalidOperationException("post-dispose error")); await Assert.That(errors).IsEmpty(); @@ -243,7 +216,7 @@ public async Task WhenCombineLatestEnumerableOnErrorResumeAfterDispose_ThenRetur await failTask; } - /// Verifies that CombineLatestEnumerable OnCompleted returns early for an already-completed index. + /// Verifies that completing a source twice yields a single overall completion. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableDoubleComplete_ThenSecondIsIgnored() @@ -266,19 +239,14 @@ public async Task WhenCombineLatestEnumerableDoubleComplete_ThenSecondIsIgnored( await src1.EmitNext(1); await src2.EmitNext(Source1Value); - // Complete src1 (has emitted, src2 still active → no overall completion) await src1.Complete(Result.Success); - - // Complete src1 again - already completed[0] = true, returns early await src1.Complete(Result.Success); - - // Now complete src2 → overall completion await src2.Complete(Result.Success); await Assert.That(completionCount).IsEqualTo(1); } - /// Verifies that CombineLatestEnumerable completes when a source completes without emitting. + /// Verifies that a source completing without a value completes the sequence at once. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableSourceCompletesWithoutValue_ThenCompletesImmediately() @@ -298,14 +266,13 @@ public async Task WhenCombineLatestEnumerableSourceCompletesWithoutValue_ThenCom return default; }); - // src1 completes without ever emitting, so shouldComplete = !_values[0].HasValue = true await src1.Complete(Result.Success); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); } - /// Verifies that CombineLatestEnumerable returns early when disposed during subscribe loop. + /// Verifies that cancelling mid-subscribe throws and leaves later sources unsubscribed. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableDisposedDuringSubscribeLoop_ThenReturnsEarly() @@ -335,27 +302,19 @@ public async Task WhenCombineLatestEnumerableDisposedDuringSubscribeLoop_ThenRet await Assert.That(normalSubscribed).IsFalse(); } - /// - /// Verifies that SubscribeAsync bails out of the source subscription loop when an - /// earlier source completes without emitting (triggering overall completion and - /// cancelling the dispose CTS) before remaining sources are subscribed, - /// covering the early-return guard in CombineLatestEnumerable.SubscribeAsync. - /// + /// Verifies that a first source completing during subscribe skips subscribing the rest. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableFirstSourceCompletesImmediately_ThenSkipsRemainingSubscriptions() { var secondSourceSubscribed = false; - // Second source records whether it was ever subscribed. var trackingSource = AsyncObs.Create((_, _) => { secondSourceSubscribed = true; return new(DisposableAsync.Empty); }); - // Empty completes immediately during subscribe, triggering overall completion - // before the loop reaches the second source. IObservableAsync[] sources = [AsyncObs.Empty(), trackingSource]; await using var sub = await sources.CombineLatest() @@ -364,11 +323,7 @@ public async Task WhenCombineLatestEnumerableFirstSourceCompletesImmediately_The await Assert.That(secondSourceSubscribed).IsFalse(); } - /// - /// Verifies that OnCompletedAsync returns early when the same source index completes - /// a second time while the subscription is still active (the _completed[index] guard), - /// covering line 268 in CombineLatestEnumerable. - /// + /// Verifies that a repeated completion from one of three sources is not counted twice. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableSameSourceCompletedTwice_ThenSecondCompletionIsIgnored() @@ -389,31 +344,22 @@ public async Task WhenCombineLatestEnumerableSameSourceCompletedTwice_ThenSecond return default; }); - // All sources emit so _values[i].HasValue is true for all. await src1.EmitNext(1); await src2.EmitNext(Source1Value); await src3.EmitNext(Source2Value); - // src1 completes: _completed[0]=true, completedCount=1 (not 3), shouldComplete=false. await src1.Complete(Result.Success); - - // src1 completes again: _completed[0] is already true, returns early (line 268). await src1.Complete(Result.Success); await Assert.That(completionCount).IsEqualTo(0); - // Finish: complete remaining sources so the subscription terminates cleanly. await src2.Complete(Result.Success); await src3.Complete(Result.Success); await Assert.That(completionCount).IsEqualTo(1); } - /// - /// Verifies that OnCompletedAsync takes the non-completing path (shouldComplete=false) - /// when a source that has already emitted a value completes while other sources remain active, - /// covering line 277 in CombineLatestEnumerable. - /// + /// Verifies that a completed source that had emitted keeps the sequence open while others run. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableSourceWithValueCompletes_ThenDoesNotCompleteUntilAllDone() @@ -442,37 +388,29 @@ public async Task WhenCombineLatestEnumerableSourceWithValueCompletes_ThenDoesNo return default; }); - // Both emit so _values[i].HasValue is true for both. await src1.EmitNext(Src1FirstValue); await src2.EmitNext(Src2FirstValue); await Assert.That(emissions).Count().IsEqualTo(1); - // src1 completes: _values[0].HasValue=true, completedCount=1 (not 2) → shouldComplete=false (line 277). await src1.Complete(Result.Success); - // Subscription is still active because shouldComplete was false. await Assert.That(completionResult).IsNull(); - // src2 can still emit and combine with src1's last value. + // src2 combines against the last value of the completed src1. await src2.EmitNext(Src2SecondValue); await Assert.That(emissions).Count().IsEqualTo(ExpectedEmissions); await Assert.That(emissions[1][0]).IsEqualTo(Src1FirstValue); await Assert.That(emissions[1][1]).IsEqualTo(Src2SecondValue); - // Complete src2 → all sources completed, shouldComplete=true. await src2.Complete(Result.Success); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); } - /// - /// Verifies that OnCompletedAsync returns early without incrementing the completed count - /// when the same source index has already completed, exercising the _completed[index] guard - /// on line 268 of CombineLatestEnumerable with a single additional source still active. - /// + /// Verifies that a repeated completion leaves the remaining source free to emit. /// A representing the asynchronous test operation. [Test] public async Task @@ -500,42 +438,32 @@ public async Task return default; }); - // Both sources emit so all _values have values. await src1.EmitNext(1); await src2.EmitNext(Source1Value); await Assert.That(emissions).Count().IsEqualTo(1); - // src1 completes: _completed[0]=true, completedCount=1, shouldComplete=false. await src1.Complete(Result.Success); await Assert.That(completionResult).IsNull(); - // Duplicate completion for index 0: _completed[0] is already true, returns default (line 268). await src1.Complete(Result.Success); - // Still no overall completion because src2 hasn't completed. await Assert.That(completionResult).IsNull(); - // src2 can still emit; the duplicate completion did not corrupt state. await src2.EmitNext(Src2LateValue); await Assert.That(emissions).Count().IsEqualTo(ExpectedEmissions); await Assert.That(emissions[1][0]).IsEqualTo(1); await Assert.That(emissions[1][1]).IsEqualTo(Src2LateValue); - // Clean termination. await src2.Complete(Result.Success); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); } - /// - /// Verifies that when one source in a three-source CombineLatest completes without ever - /// emitting a value, the shouldComplete path triggers immediate completion and the remaining - /// sources are torn down, exercising line 277 of CombineLatestEnumerable. - /// + /// Verifies that a middle source completing without a value completes the sequence with no snapshot. /// A representing the asynchronous test operation. [Test] public async Task WhenCombineLatestEnumerableMiddleSourceCompletesWithoutEmitting_ThenCompletesImmediately() @@ -563,20 +491,16 @@ public async Task WhenCombineLatestEnumerableMiddleSourceCompletesWithoutEmittin return default; }); - // src1 and src3 emit, but src2 never emits. await src1.EmitNext(Src1Value); await src3.EmitNext(Src3Value); - // No snapshot yet because src2 has not emitted. await Assert.That(emissions).IsEmpty(); - // src2 completes without emitting: !_values[1].HasValue is true → shouldComplete=true (line 277). await src2.Complete(Result.Success); await Assert.That(completionResult).IsNotNull(); await Assert.That(completionResult!.Value.IsSuccess).IsTrue(); - // No snapshots were ever emitted because not all sources had values. await Assert.That(emissions).IsEmpty(); } } diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs index f6bf6180..10f17454 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs @@ -14,7 +14,7 @@ namespace ReactiveUI.Primitives.Blazor.Reactive.Tests; /// Tests for as an driven through a fake renderer delegate. public sealed class BlazorRendererSequencerTests { - /// Expected values produced by an immediate burst, used to verify FIFO order. + /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; /// Verifies the constructor rejects a null renderer delegate. diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs index 75b30157..73accb15 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/CoalescingDispatchSchedulerTests.cs @@ -122,7 +122,7 @@ public async Task RequestingADrainWithNothingQueuedPostsNothing() await Assert.That(scheduler.PostCount).IsEqualTo(0); } - /// A second schedule coalesces onto the drain already posted for the first. + /// A second schedule coalesces onto the drain posted for the first. /// A task representing the asynchronous operation. [Test] public async Task ASecondScheduleCoalescesOntoThePendingDrain() diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs index d3efac81..5918118b 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs @@ -14,7 +14,7 @@ namespace ReactiveUI.Primitives.Blazor.Tests; /// Tests for driven through a fake renderer delegate. public sealed class BlazorRendererSequencerTests { - /// Expected values produced by an immediate burst, used to verify FIFO order. + /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; /// Verifies the constructor rejects a null renderer delegate. diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs index 96d266ab..beb81f7f 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs @@ -11,12 +11,7 @@ namespace ReactiveUI.Primitives.Blazor.Tests; -/// -/// Tests for 's observation, refresh, and disposal behaviour. Each test -/// attaches the component to an so its renderer-dispatcher-bound work (the guarded -/// invoke and StateHasChanged) actually runs, and drives the source on the dispatcher so the -/// notifications complete synchronously. -/// +/// Tests observation, refresh, and disposal against a component attached to an . public sealed class ReactiveComponentBaseTests { /// The first value pushed to a source. @@ -99,7 +94,7 @@ await harness.Dispatcher.InvokeAsync(() => await Assert.That(completed).IsTrue(); } - /// Verifies suppressing the refresh still delivers the value. + /// Verifies an observation with the refresh suppressed delivers the value to the callback. /// A task representing the asynchronous operation. [Test] public async Task ObservationWithoutRefreshStillDeliversTheValue() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/ReactiveExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/ReactiveExtensionsTests.cs index 1cc313d7..e8866e14 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/ReactiveExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/ReactiveExtensionsTests.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Tests; -/// Smoke tests confirming the Reactive Extensions leaf's recompiled shared operators behave correctly. +/// Smoke tests for the shared operators as recompiled into the Reactive Extensions leaf. public class ReactiveExtensionsTests { /// Value pushed through the signal conversion; only its type is asserted. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs index 2307fcc2..1e715cac 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/SequencerSchedulingExtensionsTests.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Tests; -/// Tests scheduling overloads. Fully qualified scheduler names avoid ambiguous System.Reactive extension methods. +/// Tests the scheduling overloads the seam adds to a System.Reactive scheduler. public class SequencerSchedulingExtensionsTests { /// State threaded through the closure-free stateful overloads. @@ -19,11 +19,12 @@ public class SequencerSchedulingExtensionsTests /// How many times the recursive overload reschedules itself before it stops. private const int ExpectedRecursiveRuns = 3; + // Fully qualified: an unqualified IScheduler makes the seam's overloads ambiguous with System.Reactive's own. /// The scheduler the seam forwards to, typed as the interface the seam extends. private static readonly System.Reactive.Concurrency.IScheduler InlineScheduler = System.Reactive.Concurrency.ImmediateScheduler.Instance; - /// A due time that has already passed, so absolute scheduling runs inline. + /// A due time in the past, so absolute scheduling runs inline. private static readonly DateTimeOffset ElapsedDueTime = DateTimeOffset.UnixEpoch; /// The state both stateful overloads must hand back, once each. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/AotSafeAssertionExtensions.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/AotSafeAssertionExtensions.cs index 89feb006..e5be3ec6 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/AotSafeAssertionExtensions.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/AotSafeAssertionExtensions.cs @@ -20,10 +20,8 @@ internal static class AotSafeAssertionExtensions where TCollection : IEnumerable { /// - /// Asserts the collection is equivalent to - /// using the element type's default - /// (order-insensitive, mirroring IsEquivalentTo's default - /// ). + /// Asserts the collection is equivalent to , order-insensitively + /// (), using the element type's default . /// /// The expected element sequence. /// The chained collection-equivalency assertion. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs index 16fc6902..58a3cf80 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs @@ -6,12 +6,10 @@ namespace ReactiveUI.Primitives.Extensions.Tests; -/// Tests for — the phase-barrier lock used to serialise emissions, -/// covering both the (Task) and -/// (ValueTask) entry points plus the already-locked short-circuit. +/// Tests for , the phase-barrier lock that serialises emissions. public class ContinuationTests { - /// Item pushed while the continuation is already locked; the barrier is expected to drop it. + /// Item pushed while the continuation holds the lock, which the barrier drops. private const int DroppedItem = 2; /// Verifies pushes the item downstream, locks, and completes once the phase is signalled by an unlock. @@ -34,7 +32,7 @@ public async Task WhenLockValueTaskNotLocked_ThenEmitsAndCompletesOnUnlock() await Assert.That(continuation.CompletedPhases).IsGreaterThanOrEqualTo(1); } - /// Verifies a second while already locked returns a completed default value task and does not push the item downstream. + /// Verifies a second taken under the lock returns a completed default value task and drops the item. /// A representing the asynchronous test operation. [Test] public async Task WhenLockValueTaskAlreadyLocked_ThenReturnsDefaultAndDropsItem() @@ -75,7 +73,7 @@ public async Task WhenLockNotLocked_ThenEmitsAndCompletesOnUnlock() await Assert.That(values.Count).IsEqualTo(1); } - /// Verifies a second while already locked returns a completed task and drops the item. + /// Verifies a second taken under the lock returns a completed task and drops the item. /// A representing the asynchronous test operation. [Test] public async Task WhenLockAlreadyLocked_ThenReturnsCompletedAndDropsItem() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs index 2c0d3058..f9b36e89 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs @@ -49,8 +49,7 @@ public async Task WhenMiddleObserverDisposed_ThenOthersStillReceive() await Assert.That(c).IsCollectionEqualTo([MultiInitialValue, Update]); } - /// Verifies that going from two observers back to one collapses to the - /// single-observer fast path while still broadcasting correctly. + /// Verifies the surviving observer keeps receiving after the second of two is disposed. /// A representing the asynchronous test operation. [Test] public async Task WhenSecondObserverDisposedFromPair_ThenSingleObserverStillReceives() @@ -67,9 +66,7 @@ public async Task WhenSecondObserverDisposedFromPair_ThenSingleObserverStillRece await Assert.That(b).IsCollectionEqualTo([MultiInitialValue]); } - /// Disposing the first observer of a 2-observer subject exercises Unsubscribe's - /// index == 0 ? existing[1] : existing[0] ternary on the true branch — the surviving - /// observer collapses back to the single-observer fast path. + /// Verifies the surviving observer keeps receiving after the first of two is disposed. /// A representing the asynchronous test operation. [Test] public async Task WhenFirstObserverOfPairDisposed_ThenSingleSurvivorReceives() @@ -81,16 +78,13 @@ public async Task WhenFirstObserverOfPairDisposed_ThenSingleSurvivorReceives() var subA = subject.Subscribe(a.Add); using var subB = subject.Subscribe(b.Add); - // Dispose subA from the two-observer array; Unsubscribe's `index == 0 ? existing[1] : existing[0]` - // ternary picks the true branch, collapsing _observer to subB. subA.Dispose(); subject.OnNext(Update); await Assert.That(a).IsCollectionEqualTo([MultiInitialValue]); await Assert.That(b).IsCollectionEqualTo([MultiInitialValue, Update]); } - /// Verifies that disposing the first observer of a 3-observer subject works - /// (collapse exercises the index==0 branch of the shrink path). + /// Verifies the remaining two observers keep receiving after the first of three is disposed. /// A representing the asynchronous test operation. [Test] public async Task WhenFirstObserverDisposed_ThenOthersStillReceive() @@ -110,8 +104,7 @@ public async Task WhenFirstObserverDisposed_ThenOthersStillReceive() await Assert.That(c).IsCollectionEqualTo([MultiInitialValue, Update]); } - /// Verifies that disposing the last observer of a 3-observer subject works - /// (collapse exercises the tail-only branch of the shrink path). + /// Verifies the remaining two observers keep receiving after the last of three is disposed. /// A representing the asynchronous test operation. [Test] public async Task WhenLastObserverDisposed_ThenOthersStillReceive() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.cs index d524226b..ebe71ba8 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/CurrentValueSubjectTests.cs @@ -101,7 +101,7 @@ public async Task WhenOnCompleted_ThenObserversReceiveCompletion() await Assert.That(completedFirst).IsTrue(); await Assert.That(completedLate).IsTrue(); - // Late subscriber still sees the replayed value before completion. + // A late subscriber receives the replayed value ahead of the completion. await Assert.That(lateValues).IsCollectionEqualTo([InitialValue]); } @@ -152,7 +152,7 @@ public async Task WhenSubscribeAfterDispose_ThenObjectDisposedExceptionDelivered await Assert.That(error).IsTypeOf(); } - /// Verifies that AsObservable returns a hide-the-observer view that still delivers values. + /// Verifies that AsObservable returns a hide-the-observer view that delivers values. /// A representing the asynchronous test operation. [Test] public async Task WhenAsObservable_ThenDeliversValuesButHidesObserverApi() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/DisposableExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/DisposableExtensionsTests.cs index 0021d708..296cefe0 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/DisposableExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/DisposableExtensionsTests.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests; /// Tests disposable extensions. public class DisposableExtensionsTests { - /// Tests DisposeWith returns a disposable. + /// Verifies DisposeWith throws when the composite it is handed is null. /// A representing the asynchronous test operation. [Test] public async Task GivenNull_WhenDisposeWith_ThenExceptionThrown() @@ -26,7 +26,7 @@ public async Task GivenNull_WhenDisposeWith_ThenExceptionThrown() await Assert.That(result).IsTypeOf(); } - /// Tests DisposeWith disposes the underlying disposable. + /// Verifies disposing the composite disposes everything added to it with DisposeWith. /// A representing the asynchronous test operation. [Test] public async Task GivenDisposable_WhenDisposeWith_ThenDisposed() @@ -43,7 +43,7 @@ public async Task GivenDisposable_WhenDisposeWith_ThenDisposed() await Assert.That(sut.IsDisposed).IsTrue(); } - /// Tests DisposeWith returns the original disposable. + /// Verifies DisposeWith returns the disposable it was called on. /// A representing the asynchronous test operation. [Test] public async Task GivenDisposable_WhenDisposeWith_ThenReturnsDisposable() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs index 66ff1004..11f55862 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs @@ -6,8 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Internal; -/// Direct RxVoid tests for — both race-claim -/// primitives are pure functions over their inputs and every branch is exercised here. +/// Tests for , the race-claim primitives. public class ConcurrencyRaceHelpersTests { /// Sentinel for the "not yet claimed" state in the tests. @@ -29,7 +28,7 @@ public async Task WhenTryClaimOpen_ThenReturnsTrueAndTransitions() await Assert.That(state).IsEqualTo(Claimed); } - /// Verifies returns false when the state is already claimed and does not mutate it further. + /// Verifies returns false against a claimed state and leaves it unchanged. /// A representing the asynchronous test operation. [Test] public async Task WhenTryClaimAlreadyClaimed_ThenReturnsFalse() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs index 8a8df817..1571fcb6 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Internal; -/// Tests for covering the error and empty-completion paths that ToHotTask does not otherwise exercise. +/// Tests for , the first-value task bridge. public class FirstAsTaskHelperTests { /// Message of an error the helper is expected to drop. @@ -60,11 +60,7 @@ public async Task WhenSourceEmitsMultiple_ThenTaskCompletesWithFirst() public void WhenSourceNull_ThenThrowsArgumentNullException() => Assert.Throws(static () => FirstAsTaskHelper.FirstAsTask(null!)); - /// Exercises the Subscription?.Dispose() null-conditional branch on - /// FirstWitness.OnNext — a source that synchronously emits during Subscribe - /// (such as ) fires OnNext before - /// FirstAsTask can assign the Subscription property, so the latch-and-cleanup - /// path sees Subscription == null and the conditional dispose becomes a no-op. + /// Verifies a source that emits during Subscribe, before the subscription handle exists, completes the task with that value. /// A representing the asynchronous test operation. [Test] public async Task WhenSyncSourceEmits_ThenSubscriptionNullBranchSkipsDispose() @@ -74,7 +70,7 @@ public async Task WhenSyncSourceEmits_ThenSubscriptionNullBranchSkipsDispose() await Assert.That(await task).IsEqualTo(Sentinel); } - /// Verifies emissions arriving after the task has already settled are silently ignored. + /// Verifies emissions arriving after the task settles are silently ignored. /// A representing the asynchronous test operation. [Test] public async Task WhenSubjectErrorsThenLaterEvents_ThenLaterEventsIgnored() @@ -89,8 +85,7 @@ public async Task WhenSubjectErrorsThenLaterEvents_ThenLaterEventsIgnored() 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. + /// Verifies a second OnNext from a source that keeps emitting is dropped by the latch. /// A representing the asynchronous test operation. [Test] public async Task WhenSecondOnNextAfterFirstSettled_ThenIgnored() @@ -132,8 +127,7 @@ public async Task WhenSecondOnCompletedAfterFirstSettled_ThenIgnored() await Assert.That(() => task).ThrowsExactly(); } - /// Test observable that captures its subscriber so tests can directly invoke - /// non-cooperative double-terminal sequences against FirstAsTaskHelper's observer. + /// Observable that captures its subscriber so a test can notify it directly. /// The element type. private sealed class InvasiveObservable : IObservable { diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs index 59c0b401..2cf85928 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs @@ -8,9 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Internal; -/// Tests for covering the value, error, and -/// empty-completion paths the pooled ToHotValueTask source exposes, plus the pool reuse and -/// post-settle drop branches. +/// Tests for , the pooled first-value task bridge. public class FirstAsValueTaskHelperTests { /// Message of an error the helper is expected to drop. @@ -62,8 +60,7 @@ public async Task WhenSourceNull_ThenThrowsArgumentNullException() => await Asse .That(static async () => await FirstAsValueTaskHelper.FirstAsValueTask(null!)) .ThrowsExactly(); - /// Exercises the Subscription?.Dispose() null-conditional branch when a source - /// synchronously emits during Subscribe before the subscription field is assigned. + /// Verifies a source that emits during Subscribe, before the subscription handle exists, yields that value. /// A representing the asynchronous test operation. [Test] public async Task WhenSyncSourceEmits_ThenSubscriptionNullBranchSkipsDispose() @@ -73,8 +70,7 @@ public async Task WhenSyncSourceEmits_ThenSubscriptionNullBranchSkipsDispose() await Assert.That(value).IsEqualTo(Sentinel); } - /// Verifies the pooled source is reused across sequential calls — a second call after the - /// first has settled returns to the pool and resolves correctly. + /// Verifies a second sequential call resolves correctly against the recycled pooled source. /// A representing the asynchronous test operation. [Test] public async Task WhenCalledSequentially_ThenPooledSourceReused() @@ -85,8 +81,7 @@ public async Task WhenCalledSequentially_ThenPooledSourceReused() await Assert.That(second).IsEqualTo(SecondValue); } - /// Verifies awaiting the value task before the source emits registers a continuation on the - /// pooled source (the incomplete-await path) and resolves once the value later arrives. + /// Verifies a value task awaited before the source emits resolves when the value arrives. /// A representing the asynchronous test operation. [Test] public async Task WhenAwaitedBeforeEmission_ThenCompletesOnLaterValue() @@ -98,7 +93,7 @@ public async Task WhenAwaitedBeforeEmission_ThenCompletesOnLaterValue() await Assert.That(result).IsEqualTo(FirstValue); } - /// Verifies emissions arriving after the value task has already settled are silently ignored. + /// Verifies emissions arriving after the value task settles are silently ignored. /// A representing the asynchronous test operation. [Test] public async Task WhenSecondTerminalAfterSettled_ThenIgnored() @@ -140,8 +135,7 @@ public async Task WhenSecondOnCompletedAfterSettled_ThenIgnored() await Assert.That(async () => await task).ThrowsExactly(); } - /// Test observable that captures its subscriber so tests can directly invoke - /// non-cooperative double-terminal sequences against the pooled first-value observer. + /// Observable that captures its subscriber so a test can notify it directly. /// The element type. private sealed class InvasiveObservable : IObservable { diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs index a047d258..6d92000d 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Internal; -/// Direct RxVoid 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. +/// Tests for , the broadcast and remove-or-null helpers. public class ObserverArrayHelpersTests { /// Sentinel value broadcast through the helper. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ReduceSinkStateTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ReduceSinkStateTests.cs index db0bbdc5..17fa9b2e 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ReduceSinkStateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ReduceSinkStateTests.cs @@ -59,7 +59,7 @@ public async Task WhenHandleError_ThenForwardsAndIsTerminal() InvalidOperationException error = new("boom"); state.HandleError(error); - state.HandleError(new InvalidOperationException("second")); // should be no-op + state.HandleError(new InvalidOperationException("second")); await Assert.That(state.IsDone).IsTrue(); await Assert.That(observer.Errors).Count().IsEqualTo(1); @@ -77,7 +77,7 @@ public async Task WhenAllSourcesComplete_ThenDownstreamCompletes() CaptureWitness observer = new(); ReduceSinkState state = new(observer, PairedSourceCount); - // Seed both values so completion-without-value path isn't triggered. + // Both slots must hold a value, or completion takes the completed-without-value path instead. state.Values[0] = SeedValue1; state.Values[1] = SeedValue2; state.HasValueCount = SeededValueCount; @@ -119,7 +119,7 @@ public async Task WhenSameSourceCompletesTwice_ThenSecondCallIgnored() ReduceSinkState state = new(observer, PairedSourceCount); state.HandleCompleted(0); - state.HandleCompleted(0); // same index — should be no-op + state.HandleCompleted(0); await Assert.That(state.CompletedCount).IsEqualTo(1); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/TimerSinkStateTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/TimerSinkStateTests.cs index 41697b91..deeaf28d 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/TimerSinkStateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/TimerSinkStateTests.cs @@ -6,8 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Internal; -/// Direct RxVoid tests for — covers the terminal -/// idempotency guards across HandleErrorLocked, HandleCompletedLocked, and HandleDisposeLocked. +/// Tests for , the shared timer sink state. public class TimerSinkStateTests { /// Verifies HandleErrorLocked forwards the error then marks the state done. @@ -40,8 +39,7 @@ public async Task WhenHandleCompletedLocked_ThenForwardsAndMarksDone() await Assert.That(observer.Completions).IsEqualTo(1); } - /// Exercises the HandleCompletedLocked idempotency guard — once the state is - /// already terminal, a second call returns without re-forwarding to the downstream. + /// Verifies HandleCompletedLocked forwards nothing once the state is terminal. /// A representing the asynchronous test operation. [Test] public async Task WhenHandleCompletedLockedAfterError_ThenNoOp() @@ -56,8 +54,7 @@ public async Task WhenHandleCompletedLockedAfterError_ThenNoOp() await Assert.That(observer.Errors).Count().IsEqualTo(1); } - /// Exercises the HandleErrorLocked idempotency guard — once the state is - /// already terminal, a second call returns without re-forwarding. + /// Verifies HandleErrorLocked forwards nothing once the state is terminal. /// A representing the asynchronous test operation. [Test] public async Task WhenHandleErrorLockedAfterCompleted_ThenNoOp() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs index 505e9ce7..e1cd54e0 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.SchedulerOverloads.cs @@ -50,7 +50,6 @@ public async Task WhenWaitForCompletionWithSchedulerOnly_ThenReturnsAfterTermina { Observable.Return(RxVoid.Default).WaitForCompletion(ImmediateSequencer.Instance); - // Sentinel follow-up to give TUnit a real assertion. var sentinel = Observable.Return(SchedulerSentinelValue).SubscribeGetValue(); await Assert.That(sentinel).IsEqualTo(SchedulerSentinelValue); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs index c84123ae..155466a7 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs @@ -41,8 +41,7 @@ public async Task WhenSubscribeGetValueEmpty_ThenReturnsDefault() [Test] public async Task WhenSubscribeAndComplete_ThenSwallowsUnitAndReturns() { - // Helper is fire-and-forget; verify a follow-up call on a different sequence still - // returns the expected value, proving SubscribeAndComplete didn't leave state behind. + // The helper returns nothing, so the follow-up call is what shows it left no state behind. Observable.Return(RxVoid.Default).SubscribeAndComplete(); var followUp = Observable.Return(RxVoid.Default).SubscribeGetValue(); await Assert.That(followUp).IsEqualTo(RxVoid.Default); @@ -175,16 +174,13 @@ public async Task WhenWaitForErrorTimesOut_ThenTimeoutException() await Assert.That(ex).IsNotNull(); } - /// Verifies the single-arg WaitForCompletion(IObservable<RxVoid>) overload — - /// pass-through to the scheduler-aware core with default timeout. + /// Verifies the single-argument WaitForCompletion overload returns on completion. /// A representing the asynchronous test operation. [Test] public async Task WhenWaitForCompletionUnitDefault_ThenReturnsOnCompletion() => await Assert.That(static () => Observable.Return(RxVoid.Default).WaitForCompletion()).ThrowsNothing(); - /// Exercises the no-op OnError body of ValueCaptureWitness — - /// SubscribeGetValue on an erroring source still returns the last captured value - /// (default) and the error is silently swallowed by the observer. + /// Verifies SubscribeGetValue swallows a source error and returns the default value. /// A representing the asynchronous test operation. [Test] public async Task WhenSubscribeGetValueSourceErrors_ThenErrorSwallowed() @@ -195,9 +191,7 @@ public async Task WhenSubscribeGetValueSourceErrors_ThenErrorSwallowed() await Assert.That(value).IsEqualTo(0); } - /// Exercises the no-op OnNext and OnCompleted bodies of - /// ErrorCaptureWitnessSubscribeGetError on a completing source ignores - /// the value and the completion, returning a null error. + /// Verifies SubscribeGetError returns a null error for a source that completes. /// A representing the asynchronous test operation. [Test] public async Task WhenSubscribeGetErrorSourceCompletesWithValue_ThenReturnsNull() @@ -207,9 +201,7 @@ public async Task WhenSubscribeGetErrorSourceCompletesWithValue_ThenReturnsNull( await Assert.That(error).IsNull(); } - /// Exercises the OnError path of BlockingValueWitness — - /// WaitForValue on an erroring source returns the default value once the gate - /// is signalled by the error. + /// Verifies a source error releases WaitForValue, which returns the default value. /// A representing the asynchronous test operation. [Test] public async Task WhenWaitForValueSourceErrors_ThenGateSignalledAndDefaultReturned() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs index 5656de05..b89078a0 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ConflateObservableTests.cs @@ -24,7 +24,7 @@ public class ConflateObservableTests /// Half of the update-period window. private const int HalfWindowTicks = 50; - /// Sentinel values. + /// First sentinel value. private const int First = 1; /// Second sentinel value. @@ -166,7 +166,7 @@ public async Task WhenOnCompletedAfterError_ThenDropped() await Assert.That(completed).IsFalse(); } - /// Verifies disposal suppresses queued notifications and callbacks already removed from the queue. + /// Verifies disposal suppresses both queued notifications and callbacks taken off the queue. /// A representing the asynchronous test operation. [Test] public async Task WhenSinkEnqueuedAfterDispose_ThenSilentlyDropped() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs index 1bea307f..cc2616a6 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DebounceImmediateObservableTests.cs @@ -6,8 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Tests for DebounceImmediateObservable covering the after-terminal guards -/// on the sink that fire only when an upstream pushes events past its own completion. +/// Tests for DebounceImmediateObservable under an upstream that notifies past its terminal. public class DebounceImmediateObservableTests { /// Tick window for the debounce. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs index 0093facf..a443c06e 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/DetectStaleObservableTests.cs @@ -16,8 +16,7 @@ public class DetectStaleObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Verifies that a source erroring synchronously during subscribe forwards the error and - /// disposes the upstream handle through the attach-after-terminated branch. + /// Verifies a source erroring during subscribe forwards the error and disposes the upstream handle. /// A representing the asynchronous test operation. [Test] public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubscription() @@ -33,17 +32,13 @@ public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubs await Assert.That(source.Subscription.IsDisposed).IsTrue(); } - /// Observable that synchronously errors during Subscribe and exposes the subscription - /// handle it returned so tests can assert it was disposed. + /// Observable that errors during Subscribe and exposes the handle it returned. /// The element type. /// The exception to emit synchronously. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2315:A type that owns a disposable should be disposable", - Justification = - "Test double that returns and exposes the BooleanDisposable subscription handle so the test can assert the " - + "operator under test disposed it. The operator owns disposal; making this double IDisposable would " - + "misattribute ownership, and the object's lifetime is the test's.")] + Justification = "The operator under test owns disposal of the exposed handle; this double only hands it back.")] private sealed class SyncErroringObservable(Exception error) : IObservable { /// Gets the subscription handle returned from the most recent subscribe. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs index e0bd4599..6db420ee 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncPathTests.cs @@ -43,8 +43,7 @@ public async Task WhenCandidatesEmpty_ThenEmitsFallbackAndCompletes() await Assert.That(completed).IsTrue(); } - /// Verifies that an async projection whose value matches the predicate emits the - /// matching value and completes — exercises the AsyncSink.OnNext match path. + /// Verifies an async projection whose value matches the predicate emits it and completes. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncProjectionMatches_ThenEmitsMatch() @@ -66,8 +65,7 @@ public async Task WhenAsyncProjectionMatches_ThenEmitsMatch() await Assert.That(results).IsCollectionEqualTo([HitKey]); } - /// Verifies that an async projection that never matches falls through to the - /// fallback when its source completes — exercises the async OnCompleted path. + /// Verifies an async projection that completes without a match falls through to the fallback. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncProjectionNeverMatches_ThenFallback() @@ -88,8 +86,7 @@ public async Task WhenAsyncProjectionNeverMatches_ThenFallback() await Assert.That(results).IsCollectionEqualTo([Fallback]); } - /// Verifies that an async projection error is swallowed and the walk continues - /// to the next candidate — exercises the async OnError path. + /// Verifies an async projection error is swallowed and the walk moves to the next candidate. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncProjectionErrors_ThenSkipsToNextCandidate() @@ -133,8 +130,7 @@ public async Task WhenDisposedDuringAsyncWalk_ThenStops() await Assert.That(completed).IsFalse(); } - /// Verifies that when the synchronous transform throws for one candidate the next - /// candidate is tried — exercises the catch { continue; } path in the sync fast path. + /// Verifies a synchronous transform that throws for one candidate lets the next be tried. /// A representing the asynchronous test operation. [Test] public async Task WhenSyncTransformThrows_ThenContinuesToNextCandidate() @@ -151,10 +147,7 @@ public async Task WhenSyncTransformThrows_ThenContinuesToNextCandidate() await Assert.That(completed).IsTrue(); } - /// Verifies that a candidate whose projected observable synchronously calls - /// OnError on the sink during its Subscribe call hits the - /// if (_looping) return; re-entrancy guard in AsyncSink.OnError and - /// proceeds to the next candidate. + /// Verifies a candidate that errors from inside its own Subscribe gives way to the next. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncCandidateProjectionSyncErrors_ThenLoopingGuardSkipsToNextCandidate() @@ -176,10 +169,7 @@ public async Task WhenAsyncCandidateProjectionSyncErrors_ThenLoopingGuardSkipsTo await Assert.That(completed).IsTrue(); } - /// Verifies that a candidate whose projected observable synchronously calls - /// OnCompleted on the sink during its Subscribe call hits the - /// if (_looping) return; re-entrancy guard in AsyncSink.OnCompleted and - /// proceeds to the next candidate. + /// Verifies a candidate that completes from inside its own Subscribe gives way to the next. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncCandidateProjectionSyncCompletes_ThenLoopingGuardSkipsToNextCandidate() @@ -197,8 +187,7 @@ public async Task WhenAsyncCandidateProjectionSyncCompletes_ThenLoopingGuardSkip await Assert.That(completed).IsTrue(); } - /// Verifies that a second async candidate emission arriving after a match has already - /// fired is silently dropped via the _done guard in AsyncSink.OnNext. + /// Verifies candidate notifications arriving after the match has fired are silently dropped. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncCandidateEmitsAfterMatch_ThenDroppedByDoneGuard() @@ -218,14 +207,11 @@ public async Task WhenAsyncCandidateEmitsAfterMatch_ThenDroppedByDoneGuard() await Assert.That(results).IsCollectionEqualTo([HitKey]); } - /// Drives AsyncSink.TryNext through a candidate whose projection - /// synchronously errors — that path enters AsyncSink.OnError while _looping == true - /// (inside TryNext), exercising the if (_looping) return; guard. + /// Verifies a mid-walk candidate that errors from inside Subscribe lets the walk reach the match. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncSinkWalkHitsSyncErroringCandidate_ThenLoopingGuardSkipsAhead() { - // The second candidate errors synchronously after the first asynchronous candidate completes. string[] keys = [AsyncKey, SyncErrorKey, HitKey]; Subject asyncSubject = new(); List results = []; @@ -241,15 +227,13 @@ public async Task WhenAsyncSinkWalkHitsSyncErroringCandidate_ThenLoopingGuardSki static value => value == HitKey, Fallback).Subscribe(results.Add, () => completed.TrySetResult()); - // Completing the first candidate starts the synchronously failing candidate. + // Completing the first candidate is what advances the walk onto the re-entrant one. asyncSubject.OnCompleted(); await completed.Task; await Assert.That(results).IsCollectionEqualTo([HitKey]); } - /// Same shape as the looping-error case but the intermediate candidate - /// synchronously completes instead of erroring, exercising - /// AsyncSink.OnCompleted's _looping guard. + /// Verifies a mid-walk candidate that completes from inside Subscribe lets the walk reach the match. /// A representing the asynchronous test operation. [Test] public async Task WhenAsyncSinkWalkHitsSyncCompletingCandidate_ThenLoopingGuardSkipsAhead() @@ -273,8 +257,7 @@ public async Task WhenAsyncSinkWalkHitsSyncCompletingCandidate_ThenLoopingGuardS await Assert.That(results).IsCollectionEqualTo([HitKey]); } - /// Observable that synchronously calls OnError on the subscriber from inside - /// its Subscribe method — used to exercise the re-entrancy _looping guard. + /// Observable that calls OnError on its subscriber from inside Subscribe. /// The element type. /// The exception to deliver to the subscriber. private sealed class SyncErroringObservable(Exception error) : IObservable @@ -287,9 +270,7 @@ public IDisposable Subscribe(IObserver observer) } } - /// Observable that synchronously calls OnCompleted on the subscriber from - /// inside its Subscribe method — used to exercise the re-entrancy _looping - /// guard. + /// Observable that calls OnCompleted on its subscriber from inside Subscribe. /// The element type. private sealed class SyncCompletingObservable : IObservable { diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncTests.cs index 96961932..ca6fb407 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesAsyncTests.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Tests covering the async-sink path of -/// FirstMatchFromCandidates (when projections do not complete synchronously) -/// and edge cases not exercised by the sync fast-path tests. +/// Tests FirstMatchFromCandidates where the candidate projections do not complete synchronously. public class FirstMatchFromCandidatesAsyncTests { /// Transformed result produced from the matching raw value. @@ -156,7 +154,7 @@ public async Task WhenAsyncProjectionFactoryThrows_ThenSkipsAndContinues() static t => t == MatchResult, Fallback).Subscribe(results.Add); - // Trigger async sink: complete the first projection so AsyncSink.TryNext walks the rest. + // Completing the first projection is what advances the walk onto the remaining candidates. asyncFirst.OnCompleted(); await Assert.That(results).IsCollectionEqualTo([MatchResult]); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs index fd4d54ec..c822cb95 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ForEachObservableTests.cs @@ -8,8 +8,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Tests for — null-batch ignore semantics, the -/// scheduler-marshalled delivery path, error forwarding, and the null-observer subscribe guard. +/// Tests for — null batches, scheduled delivery, and error forwarding. public class ForEachObservableTests { /// Sentinel batch element. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs index 9c87ba86..c3c3d1de 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/HeartbeatObservableTests.cs @@ -91,8 +91,7 @@ public async Task WhenHeartbeatSourceEmits_ThenForwardsValueUpdate() await Assert.That(updates).IsCollectionEqualTo([Value]); } - /// Verifies that OnNext, OnError and a duplicate OnCompleted - /// arriving after the source has already completed are silently dropped. + /// Verifies notifications arriving after the source completes are silently dropped. /// A representing the asynchronous test operation. [Test] public async Task WhenEventsAfterCompleted_ThenDropped() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/LogErrorsObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/LogErrorsObservableTests.cs index 20315947..41fd67a7 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/LogErrorsObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/LogErrorsObservableTests.cs @@ -7,8 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Tests.Operators; -/// Tests for — verifies the logger is tapped on -/// the error path, never on the success path, and that the null-observer subscribe guard fires. +/// Tests for — when the logger is tapped, and the null-observer guard. public class LogErrorsObservableTests { /// Sentinel value flowing through the success path. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs index 74161728..7bb1b746 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/MinMaxObservableTests.cs @@ -82,7 +82,7 @@ public async Task WhenGetMaxSourceErrors_ThenForwardsError() await Assert.That(caught).IsSameReferenceAs(expected); } - /// Verifies GetMax with no additional sources still emits the source's own values verbatim. + /// Verifies GetMax with no additional sources emits the source's own values verbatim. /// A representing the asynchronous test operation. [Test] public async Task WhenGetMaxSingleSource_ThenEmitsSourceValues() @@ -96,7 +96,7 @@ public async Task WhenGetMaxSingleSource_ThenEmitsSourceValues() await Assert.That(results).IsCollectionEqualTo([LowValue, MidValue, HighValue]); } - /// Verifies GetMin with no additional sources still emits the source's own values verbatim. + /// Verifies GetMin with no additional sources emits the source's own values verbatim. /// A representing the asynchronous test operation. [Test] public async Task WhenGetMinSingleSource_ThenEmitsSourceValues() diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs index bc56ce74..da73dc42 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnIfObservableTests.cs @@ -84,8 +84,7 @@ public async Task WhenObserveOnIfSourceCompletes_ThenForwardsCompletion() await Assert.That(completed).IsTrue(); } - /// Verifies that the single-scheduler overload defaults the false branch to - /// by emitting synchronously when the condition is false. + /// Verifies the single-scheduler overload emits synchronously while the condition is false. /// A representing the asynchronous test operation. [Test] public async Task WhenObserveOnIfSingleSchedulerConditionFalse_ThenImmediate() @@ -123,10 +122,7 @@ public async Task WhenOnNextAfterCompleted_ThenDropped() await Assert.That(values).IsEmpty(); } - /// Exercises the _done guard inside the scheduled callback — - /// when the source completes between OnNext's schedule call and the scheduler firing - /// the queued callback, the callback observes _done == true and returns without - /// forwarding to downstream. + /// Verifies a queued emission is dropped when the source completes before the callback fires. /// A representing the asynchronous test operation. [Test] public async Task WhenScheduledCallbackFiresAfterSourceCompleted_ThenDroppedByDoneGuard() @@ -139,22 +135,16 @@ public async Task WhenScheduledCallbackFiresAfterSourceCompleted_ThenDroppedByDo using var sub = source.ObserveOnIf(condition, scheduler, scheduler) .Subscribe(values.Add, () => completedCount++); - // First emission queues the forward-to-downstream callback on the VirtualClock. + // The emission is queued on the clock, so the completion below lands ahead of it. source.Observer.OnNext(1); - - // Source completes synchronously, flipping _done = true before the queued callback runs. source.Observer.OnCompleted(); - // Advance the scheduler so the queued callback fires; it observes _done == true and - // returns at the in-callback guard rather than calling downstream.OnNext. scheduler.AdvanceBy(1); await Assert.That(completedCount).IsEqualTo(1); await Assert.That(values).IsEmpty(); } - /// Verifies the condition observer's duplicate-value short-circuit — emitting the - /// same condition value twice in a row hits the _hasCondition & & _lastCondition == c - /// guard and returns silently without re-assigning the current scheduler. + /// Verifies a repeated condition value is ignored and leaves the selected scheduler in place. /// A representing the asynchronous test operation. [Test] public async Task WhenObserveOnIfConditionDuplicate_ThenSilentlyShortCircuits() @@ -166,15 +156,10 @@ public async Task WhenObserveOnIfConditionDuplicate_ThenSilentlyShortCircuits() List values = []; using var sub = source.ObserveOnIf(condition, trueScheduler, falseScheduler).Subscribe(values.Add); - // First emission seeds the gate (_hasCondition transitions from false to true). condition.OnNext(true); - - // Second identical emission hits the duplicate-value guard and returns early. condition.OnNext(true); source.OnNext(1); - // Sanity: subsequent value still routes through the true-scheduler (the duplicate did - // not corrupt the captured state). await Assert.That(values).IsCollectionEqualTo([1]); } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs index e00a6485..5b3e4a93 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/ObserveOnObservableTests.cs @@ -15,11 +15,10 @@ public class ObserveOnObservableTests /// Synthetic error message attached to source errors. private const string SourceErrorMessage = "source error"; - /// Second sentinel value (kept as a constant to satisfy the no-magic-number rule). + /// Second sentinel value. private const int SecondValue = 2; - /// Verifies the immediate scheduler is special-cased to forward straight through the source - /// subscription without the queue-and-drain machinery. + /// Verifies the immediate scheduler forwards straight through, without queue-and-drain. /// A representing the asynchronous test operation. [Test] public async Task WhenImmediateScheduler_ThenForwardsDirectly() @@ -103,9 +102,7 @@ public async Task WhenDisposedBeforeDrain_ThenTearsDownAndDropsQueued() await Assert.That(values).IsEmpty(); } - /// Verifies the upstream subscription is disposed when the source terminates synchronously during - /// subscribe — the drain runs inline (terminating the sink) before AttachSourceSubscription records - /// the handle, so the late attach disposes it instead. + /// Verifies the upstream handle is disposed when the source terminates during subscribe, before the sink records it. /// A representing the asynchronous test operation. [Test] public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubscription() @@ -120,17 +117,13 @@ public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubs await Assert.That(source.Subscription.IsDisposed).IsTrue(); } - /// Observable that synchronously errors during Subscribe and exposes the subscription - /// handle it returned so tests can assert it was disposed. + /// Observable that errors during Subscribe and exposes the handle it returned. /// The element type. /// The exception to emit synchronously. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2315:A type that owns a disposable should be disposable", - Justification = - "Test double that returns and exposes the BooleanDisposable subscription handle so the test can assert the " - + "operator under test disposed it. The operator owns disposal; making this double IDisposable would " - + "misattribute ownership, and the object's lifetime is the test's.")] + Justification = "The operator under test owns disposal of the exposed handle; this double only hands it back.")] private sealed class SyncErroringObservable(Exception error) : IObservable { /// Gets the subscription handle returned from the most recent subscribe. @@ -162,10 +155,7 @@ private sealed class InlineScheduler : ISequencer [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "SST2318:Members should not have identical bodies", - Justification = - "The relative and absolute Schedule overloads of this test-double sequencer intentionally behave the " - + "same way; both are required by the ISequencer contract and, as distinct interface overloads, cannot " - + "forward to one another.")] + Justification = "Both ISequencer Schedule overloads must exist and run inline; neither can forward to the other.")] public void Schedule(IWorkItem item, long dueTimestamp) => item.Execute(); } } diff --git a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs index e89e69ff..68c8cc03 100644 --- a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs @@ -9,13 +9,10 @@ namespace ReactiveUI.Primitives.Maui.Reactive.Tests; -/// -/// Tests for as an , exercised through a fake -/// so the immediate and time-based dispatch paths run deterministically on any platform. -/// +/// Tests as an through a fake . public sealed class MauiDispatcherSequencerTests { - /// Expected values produced by an immediate burst, used to verify FIFO order. + /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; /// Verifies the constructor rejects a null dispatcher. @@ -54,10 +51,7 @@ public async Task ImmediateScheduleDispatchesAndExecutes() await Assert.That(dispatcher.DispatchDelayedCount).IsEqualTo(0); } - /// - /// Verifies work due in the future routes through - /// with a positive delay, and runs on the dispatcher without the shared-timer marshal hop. - /// + /// Verifies future work routes through with a positive delay. /// A representing the asynchronous operation. [Test] public async Task DelayedScheduleUsesDispatchDelayed() diff --git a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs index d92168d1..bea3957c 100644 --- a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs @@ -8,13 +8,10 @@ namespace ReactiveUI.Primitives.Maui.Tests; -/// -/// Tests for , exercised through a fake -/// so the immediate and time-based dispatch paths run deterministically on any platform. -/// +/// Tests 's immediate and time-based dispatch paths through a fake . public sealed class MauiDispatcherSequencerTests { - /// Expected values produced by an immediate burst, used to verify FIFO order. + /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; /// Verifies the constructor rejects a null dispatcher. @@ -53,10 +50,7 @@ public async Task ImmediateScheduleDispatchesAndExecutes() await Assert.That(dispatcher.DispatchDelayedCount).IsEqualTo(0); } - /// - /// Verifies work due in the future routes through - /// with a positive delay, and runs on the dispatcher without the thread-pool marshal hop. - /// + /// Verifies future work routes through with a positive delay. /// A representing the asynchronous operation. [Test] public async Task DelayedScheduleUsesDispatchDelayed() @@ -72,7 +66,7 @@ public async Task DelayedScheduleUsesDispatchDelayed() await Assert.That(dispatcher.LastDelay).IsGreaterThan(TimeSpan.Zero); } - /// Verifies a due timestamp at or before now takes the immediate path rather than the delayed timer. + /// Verifies a due timestamp that is not in the future takes the immediate path rather than the delayed timer. /// A representing the asynchronous operation. [Test] public async Task PastDueTimestampUsesImmediatePath() diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/ContainerDisposableTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/ContainerDisposableTests.cs index 95c74fea..cebf443e 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/ContainerDisposableTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/ContainerDisposableTests.cs @@ -109,7 +109,7 @@ public async Task ConversionThrowsForNullContainer() await Assert.That(exception.ParamName).IsEqualTo("container"); } - /// Verifies the container still behaves as a group of disposables in its own right. + /// Verifies the container behaves as a group of disposables in its own right. /// A task representing the asynchronous operation. [Test] public async Task ContainerTracksItsOwnRegistrationsAlongsideTheComposite() diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs index 6b02aa07..6b9bbe98 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/LinqExtensionsTests.cs @@ -94,7 +94,7 @@ public async Task DisposeWithThrowsForNullContainerDisposable() await Assert.That(exception.ParamName).IsEqualTo("disposables"); } - /// Verifies a container converted to a composite still disposes what System.Reactive registered on it. + /// Verifies a container converted to a composite disposes what System.Reactive registered on it. /// A task representing the asynchronous operation. [Test] public async Task DisposeWithComposedFromAContainerDisposesWithTheContainer() diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs index a3705caf..f011f15f 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerSchedulingExtensionsTests.Overloads.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Reactive.Tests; -/// Tests scheduling overloads. Fully qualified scheduler names avoid ambiguous System.Reactive extension methods. +/// Tests the scheduling overloads the seam adds to a System.Reactive scheduler. public partial class SequencerSchedulingExtensionsTests { /// State threaded through the closure-free stateful overloads. @@ -19,11 +19,12 @@ public partial class SequencerSchedulingExtensionsTests /// How many times the recursive overload reschedules itself before it stops. private const int ExpectedRecursiveRuns = 3; + // Fully qualified: an unqualified IScheduler makes the seam's overloads ambiguous with System.Reactive's own. /// The scheduler the seam forwards to, typed as the interface the seam extends. private static readonly System.Reactive.Concurrency.IScheduler InlineScheduler = System.Reactive.Concurrency.ImmediateScheduler.Instance; - /// A due time that has already passed, so absolute scheduling runs inline. + /// A due time in the past, so absolute scheduling runs inline. private static readonly DateTimeOffset ElapsedDueTime = DateTimeOffset.UnixEpoch; /// The state both stateful overloads must hand back, once each. diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerTests.cs index afaacbfd..3c8e26e0 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SequencerTests.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Reactive.Tests; -/// -/// Verifies maps each built-in sequencer onto the matching System.Reactive -/// scheduler singleton, and that time normalization forwards to System.Reactive. -/// +/// Verifies maps each built-in sequencer onto its System.Reactive scheduler and forwards interval normalization. public class SequencerTests { /// A negative interval, which normalization clamps to zero. diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SignalTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SignalTests.cs index 8de58bd1..8c179e1d 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/SignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/SignalTests.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Tests; -/// Smoke tests confirming the Reactive leaf's recompiled shared signals behave correctly. +/// Smoke tests for the shared signal surface as recompiled into the Reactive leaf. public class SignalTests { /// The values replayed by Signal.FromEnumerable. diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs index 67215fb5..04b3d0f5 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Reactive.Tests; /// Tests for . public sealed class WasmSchedulerTests { - /// State payload used to verify state threading. + /// State payload threaded through the scheduled action. private const int StatePayload = 42; /// Periodic ticks a test drives before disposing; more than one, so threaded state is observable. @@ -23,7 +23,7 @@ public sealed class WasmSchedulerTests /// Sentinel returns when no element matches. private const int NoMatch = -1; - /// Expected values produced by an immediate burst, used to verify FIFO order. + /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; /// Due time of a work item that is expected to run after its delay elapses. @@ -119,7 +119,7 @@ public async Task ImmediateBurstExecutesInOrder() await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } - /// Verifies an item disposed while it waits in the ready queue is skipped while later work still runs. + /// Verifies an item disposed while it waits in the ready queue is skipped and later work runs. /// A representing the asynchronous operation. [Test] public async Task DisposedImmediateItemIsSkipped() @@ -194,7 +194,7 @@ public async Task DisposedDelayedItemDoesNotRun() subscription.Dispose(); - // The due time never elapses on its own, so this is the run a timer callback already in flight would deliver. + // The due time never elapses on its own, so this stands in for a timer callback in flight. subscription.Run(); await Assert.That(subscription.IsDisposed).IsTrue(); @@ -294,7 +294,7 @@ public async Task DisposedPeriodicItemDisposeIsIdempotent() await Assert.That(subscription.Dispose).ThrowsNothing(); } - /// Verifies an action that cancels its own item before returning still has its returned disposable released. + /// Verifies an action that cancels its own item before returning has its returned disposable released. /// A representing the asynchronous operation. [Test] public async Task SelfCancellingImmediateActionDisposesReturnedDisposable() @@ -321,27 +321,27 @@ public async Task SelfCancellingImmediateActionDisposesReturnedDisposable() await Assert.That(returned.IsDisposed).IsTrue(); } - /// Verifies that scheduling with a null action returns proper exception. + /// Verifies the shared scheduler's immediate overload rejects a null action. /// A representing the asynchronous operation. [Test] public async Task ScheduleWithNullActionThrows() => await Assert.That(static () => WasmScheduler.Default.Schedule(0, null!)).Throws(); - /// Verifies that scheduling delayed with a null action returns proper exception. + /// Verifies the shared scheduler's delayed overload rejects a null action. /// A representing the asynchronous operation. [Test] public async Task ScheduleDelayedWithNullActionThrows() => await Assert .That(static () => WasmScheduler.Default.Schedule(0, ValidInterval, null!)) .Throws(); - /// Verifies that scheduling periodic with negative period throws. + /// Verifies the shared scheduler's periodic overload rejects a negative period. /// A representing the asynchronous operation. [Test] public async Task SchedulePeriodicWithNegativePeriodThrows() => await Assert .That(static () => WasmScheduler.Default.SchedulePeriodic(0, TimeSpan.FromMilliseconds(-1), static s => s)) .Throws(); - /// Verifies that scheduling periodic with null action returns proper exception. + /// Verifies the shared scheduler's periodic overload rejects a null action. /// A representing the asynchronous operation. [Test] public async Task SchedulePeriodicWithNullActionThrows() => await Assert @@ -379,10 +379,7 @@ await Assert.That(() => scheduler.SchedulePeriodic(0, UnreachablePeriod, state = await Assert.That(ran).IsEqualTo(0); } - /// - /// Verifies disposing the scheduler cancels work still waiting in the ready queue while a drain is in flight: - /// the queued item is released, not left for the resuming drain to run against a scheduler that is already gone. - /// + /// Verifies disposing the scheduler releases a queued item rather than leaving it for a resuming drain to run. /// A representing the asynchronous operation. [Test] public async Task DisposeCancelsWorkTheInFlightDrainHasNotReachedYet() @@ -395,7 +392,7 @@ public async Task DisposeCancelsWorkTheInFlightDrainHasNotReachedYet() return Disposable.Empty; }); - // Queue without arming a drain, so the item is provably still waiting when the disposal runs. + // Queue without arming a drain, so the item sits unclaimed in the queue when the disposal runs. scheduler.QueueReady(queued); scheduler.Dispose(); @@ -474,7 +471,7 @@ public async Task EnqueueThatLosesTheRaceToDisposeReleasesTheItemItQueued() scheduler.Dispose(); - // The enqueue that was already past Schedule's disposed check when the disposal drained the ready queue. + // Models an enqueue that passed Schedule's disposed check before the disposal drained the ready queue. scheduler.Enqueue(item); await Assert.That(item.IsDisposed).IsTrue(); @@ -485,10 +482,7 @@ public async Task EnqueueThatLosesTheRaceToDisposeReleasesTheItemItQueued() await Assert.That(ran).IsEqualTo(0); } - /// - /// Verifies a drain pass dispatches every queued item exactly once: an item the batch skips would be stranded, - /// and one it claims twice would run its action twice. - /// + /// Verifies a drain pass dispatches every queued item exactly once. /// A representing the asynchronous operation. [Test] public async Task DrainDispatchesEveryQueuedItemExactlyOnce() diff --git a/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs index d8f19a47..65399ba8 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BlendUniqueTests.cs @@ -68,7 +68,7 @@ public async Task EmptySourcesCompletesImmediately() await Assert.That(completed).IsEqualTo(Once); } - /// Verifies that a custom comparer is used to suppress duplicates. + /// Verifies that a custom comparer decides which values count as duplicates. /// A task representing the asynchronous operation. [Test] public async Task UsesSuppliedComparer() diff --git a/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs index 63efd95b..82600ca7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ChooseSwitchSelectTests.cs @@ -20,7 +20,7 @@ public class ChooseSwitchSelectTests /// The value twenty. private const int Twenty = 20; - /// The divisor used to select even values in the Choose test. + /// The divisor that selects even values in the Choose test. private const int Two = 2; /// The expected single-occurrence count. diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs index cb79a139..c781cc58 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/ConsumerCompilation.cs @@ -10,8 +10,8 @@ namespace ReactiveUI.Primitives.Tests; /// -/// Compiles consumer code against the shipped Primitives assemblies to assert what a downstream compiler resolves. -/// Overload resolution against a referenced assembly binds through metadata, not through this assembly's source. +/// Compiles consumer code against the shipped Primitives assemblies, so overload resolution binds through metadata +/// the way a downstream project sees it rather than through this assembly's source. /// public static class ConsumerCompilation { diff --git a/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs b/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs index 505dabf5..28072af4 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/Common/MinimalVirtualClock.cs @@ -25,7 +25,7 @@ internal static class MinimalVirtualClock internal static VirtualTimeSequencer Create() => Create(Comparer.Default); /// Creates a tick-based virtual sequencer ordered by the supplied comparer. - /// The comparer used to order scheduled times. + /// The comparer that orders scheduled times. /// The virtual sequencer. internal static VirtualTimeSequencer Create(IComparer comparer) => new(0L, comparer, Adder, ToDateTimeOffset, ToRelative); diff --git a/src/tests/ReactiveUI.Primitives.Tests/GuardedWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/GuardedWitnessTests.cs index 93d04dfe..eb16657c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/GuardedWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/GuardedWitnessTests.cs @@ -13,7 +13,7 @@ public sealed class GuardedWitnessTests /// Value forwarded through the guard. private const int One = 1; - /// Second value used to verify forwarding continues after a value. + /// The second value, which shows forwarding continues after the first. private const int Two = 2; /// Verifies the constructor rejects null arguments. diff --git a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs index 802da0c3..14a96867 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs @@ -39,7 +39,7 @@ public sealed class PrioritySemaphoreSignalTests /// The number of values queued before draining. private const int SeededValueCount = 12; - /// The initial capacity used to drain queued values. + /// The initial capacity that governs how many queued values drain. private const int InitialDrainCapacity = 3; /// The amount added during alternating capacity updates. diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs index b609d5a3..b34455d1 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ReplaySignalTests.cs @@ -303,7 +303,7 @@ private static async Task AssertWindowedReplayDropsValuesOlderThanTheWindow() } /// Checks gate ownership and ordered handover from replay to live delivery. - /// Factory used to create the replay signal under test. + /// Factory that creates the replay signal under test. /// A task representing the asynchronous operation. private static async Task AssertReplayAndLiveDeliveryHoldTheGate(Func> factory) { @@ -327,7 +327,7 @@ private static async Task AssertReplayAndLiveDeliveryHoldTheGate(FuncCreates a replay signal and disposes it immediately. - /// Factory used to create the signal. + /// Factory that creates the signal. private static void CreateAndDispose(Func> factory) { using var signal = factory(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Repeat.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Repeat.cs index b240d957..4d7a650b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Repeat.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Repeat.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Primitives.Tests; /// Repeat operator parity tests for System.Reactive-compatible names. public partial class RxNamesTests { - /// The value four, used to bound the infinite repeat test. + /// The value four, which bounds the infinite repeat test. private const int Four = 4; /// The expected values when a two-value source is repeated twice. diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs index 5aa743f1..96a7d6ea 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs @@ -17,7 +17,7 @@ public partial class RxNamesTests /// The multiplier/state used by projection cases. private const int Ten = 10; - /// The divisor used to select even values. + /// The divisor that selects even values. private const int Two = 2; /// The fold/aggregate seed. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs index aaea38ed..389c6e3b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs @@ -616,7 +616,7 @@ private static void CreateSynchronizationContextSequencerWithoutContext() /// Completes a task completion source, rejecting a second completion. /// The completion source to update. /// The value to set. - /// was already completed. + /// has completed. private static void SetCompletion(TaskCompletionSource completion, int state) { if (completion.TrySetResult(state)) diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs index 21431ff9..14889e18 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs @@ -54,7 +54,7 @@ public partial class SignalFactoriesTests /// The fourth projected value after applying the projection multiplier. private const int ProjectedFourthValue = 21; - /// A peer value used to verify distinct-by bucketing. + /// A peer value that shares a distinct-by bucket. private const int ProjectedSecondBucketPeerValue = 12; /// The zip result expected from the first pair. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs index 8dff500b..b0b0ce59 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.EventConversion.cs @@ -269,7 +269,7 @@ await Assert.That(static () => Signal.FromEventPattern, static _ => { })).Throws(); } - /// A sender used to check the typed-sender event pattern keeps its static type. + /// A sender whose static type the typed-sender event pattern must preserve. [System.Diagnostics.DebuggerDisplay("EventBroadcaster: Id = {Id}")] public sealed class EventBroadcaster { diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs index e46811df..241dceb9 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Scheduling.cs @@ -181,7 +181,7 @@ await Assert.That(((IRequireCurrentThread)Signal.Loop(Seven)).IsRequiredSub await Assert.That(witness.Completed).IsEqualTo(1); } - /// A loop subscription that is already torn down never emits. + /// A loop subscription torn down before the loop runs never emits. /// A task representing the asynchronous operation. [Test] public async Task LoopEmitsNothingWhenTheSubscriptionIsDisposedBeforeItRuns() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs index ca196fa6..8d755230 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalOperatorMixinsTests.cs @@ -768,7 +768,7 @@ private static void VerifyCollectOperators(IObservable source) } /// Asserts the prepend, observe-on, subscribe-on, and tap argument guards. - /// A non-null source used to exercise instance guards. + /// A non-null source for the instance overloads. private static void AssertPrependObserveTapGuards(IObservable source) { _ = Assert.Throws(static () => LinqExtensions.Prepend(null!, One, Two)); @@ -786,7 +786,7 @@ private static void AssertPrependObserveTapGuards(IObservable source) } /// Asserts the aggregate, flat-map, and timing operator argument guards. - /// A non-null source used to exercise instance guards. + /// A non-null source for the instance overloads. private static void AssertAggregateAndTimingGuards(IObservable source) { _ = Assert.Throws(static () => LinqExtensions.IgnoreValues(null!)); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs index 524e4a4e..431903c4 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalRecoverTests.cs @@ -77,7 +77,7 @@ public async Task RecoverAndCleanupOperatorsRequireCurrentThreadSubscription() await Assert.That(((IRequireCurrentThread)cleanup).IsRequiredSubscribeOnCurrentThread()).IsTrue(); } - /// A cleanup action runs even when subscribing to the source throws, and the failure still surfaces. + /// A cleanup action runs when subscribing to the source throws, and the failure surfaces. /// A task representing the asynchronous operation. [Test] public async Task OnCleanupRunsTheActionWhenSubscribingToTheSourceThrows() diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs index 6fcf32a8..20678a05 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs @@ -698,7 +698,7 @@ private static void AssertObserverFailuresPropagateOutOfSubscribe() .Subscribe((IObserver)null!)); } - /// A minimal probe used to exercise the subscription routing. + /// A minimal probe that reports how a subscription was routed. /// The type of the signal sequence elements. private sealed class SignalsBaseProbe : IRequireCurrentThread { @@ -723,7 +723,7 @@ public IDisposable Subscribe(IObserver observer) => /// Performs the core subscription by returning an empty disposable. /// The observer to subscribe. - /// The disposable used to cancel the subscription. + /// The disposable that cancels the subscription. /// An empty disposable. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage("Maintainability", "SST1461:Remove unread private parameters", Justification = "The signature is fixed by the delegate SignalSubscription.Subscribe expects.")] diff --git a/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs index 292806d1..bbd62191 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SinkObserverTests.cs @@ -655,7 +655,7 @@ public async Task TakeSinksDropFaultsThatArriveAfterTheirTerminal() } /// Asserts a sink rethrows the observer's failure and releases its upstream subscription. - /// The sink under test, already holding . + /// The sink under test, holding . /// The upstream subscription the sink must release. /// A task representing the asynchronous operation. private static async Task AssertObserverFailureReleasesUpstream( diff --git a/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs index cd60b818..203307d0 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/WitnessOnSignalTests.cs @@ -20,7 +20,7 @@ public sealed class WitnessOnSignalTests /// The second value pushed through the dispatch queue. private const int Second = 2; - /// A single virtual tick used to drain the sequencer queue. + /// A single virtual tick, enough to drain the sequencer queue. private static readonly TimeSpan SingleTick = TimeSpan.FromTicks(1); /// The values expected when only the first queued notification is delivered. diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/CrossPlatformPlaceholderTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/CrossPlatformPlaceholderTests.cs index 40a58044..62662fe4 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/CrossPlatformPlaceholderTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/CrossPlatformPlaceholderTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.WinForms.Reactive.Tests; -/// -/// Placeholder so the cross-platform test host runs a test and does not report a zero-test run. -/// The real Windows Forms control tests compile and run on Windows only (see the csproj). -/// +/// Placeholder keeping the non-Windows test host from reporting a zero-test run. public sealed class CrossPlatformPlaceholderTests { /// Verifies this build leg is the cross-platform one, where the Windows Forms control tests are absent. diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Tests/CrossPlatformPlaceholderTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Tests/CrossPlatformPlaceholderTests.cs index 5f03ea03..70df6fd8 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Tests/CrossPlatformPlaceholderTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Tests/CrossPlatformPlaceholderTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.WinForms.Tests; -/// -/// Placeholder so the cross-platform test host runs a test and does not report a zero-test run. -/// The real WinForms control tests compile and run on Windows only (see the csproj). -/// +/// Placeholder keeping the non-Windows test host from reporting a zero-test run. public sealed class CrossPlatformPlaceholderTests { /// Verifies this build leg is the cross-platform one, where the WinForms control tests are absent. diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/CrossPlatformPlaceholderTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/CrossPlatformPlaceholderTests.cs index 1a6a4d16..b7cc58a7 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/CrossPlatformPlaceholderTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/CrossPlatformPlaceholderTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.WinUI.Reactive.Tests; -/// -/// Placeholder so the cross-platform test host runs a test and does not report a zero-test run. -/// The real WinUI dispatcher tests compile and run on Windows only (see the csproj). -/// +/// Placeholder keeping the non-Windows test host from reporting a zero-test run. public sealed class CrossPlatformPlaceholderTests { /// Verifies this build leg is the cross-platform one, where the WinUI dispatcher tests are absent. diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Tests/CrossPlatformPlaceholderTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Tests/CrossPlatformPlaceholderTests.cs index ce7194cc..7c0fca2d 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Tests/CrossPlatformPlaceholderTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Tests/CrossPlatformPlaceholderTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.WinUI.Tests; -/// -/// Placeholder so the cross-platform test host runs a test and does not report a zero-test run. -/// The real WinUI dispatcher tests compile and run on Windows only (see the csproj). -/// +/// Placeholder keeping the non-Windows test host from reporting a zero-test run. public sealed class CrossPlatformPlaceholderTests { /// Verifies this build leg is the cross-platform one, where the WinUI dispatcher tests are absent. diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/CrossPlatformPlaceholderTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/CrossPlatformPlaceholderTests.cs index ef5a66bc..7ead6de6 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/CrossPlatformPlaceholderTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/CrossPlatformPlaceholderTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Wpf.Reactive.Tests; -/// -/// Placeholder so the cross-platform test host runs a test and does not report a zero-test run. -/// The real WPF dispatcher tests compile and run on Windows only (see the csproj). -/// +/// Placeholder keeping the non-Windows test host from reporting a zero-test run. public sealed class CrossPlatformPlaceholderTests { /// Verifies this build leg is the cross-platform one, where the WPF dispatcher tests are absent. diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Tests/CrossPlatformPlaceholderTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Tests/CrossPlatformPlaceholderTests.cs index 535a09b8..46affbe6 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Tests/CrossPlatformPlaceholderTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Tests/CrossPlatformPlaceholderTests.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Wpf.Tests; -/// -/// Placeholder so the cross-platform test host runs a test and does not report a zero-test run. -/// The real WPF dispatcher tests compile and run on Windows only (see the csproj). -/// +/// Placeholder keeping the non-Windows test host from reporting a zero-test run. public sealed class CrossPlatformPlaceholderTests { /// Verifies this build leg is the cross-platform one, where the WPF dispatcher tests are absent. From d01b2b2ae977647687418c1120ea82e35fda0692 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:02:37 +1000 Subject: [PATCH 05/11] docs: state what non-public members do, not why - Reduce internal operator and helper summaries to a single line of behaviour. - Drop allocation and deadlock rationale from field and method summaries. - Keep remarks for public contracts a caller cannot infer from the signature. --- .../Operators/ConflateObservable.cs | 7 +------ .../Operators/DebounceImmediateObservable.cs | 5 +---- .../Operators/DebounceUntilObservable.cs | 6 +----- .../Operators/DetectStaleObservable.cs | 6 +----- .../Operators/ObserveOnIfObservable.cs | 7 +------ .../Operators/RetryWithBackoffObservable.cs | 6 +----- .../Operators/StartActionObservable.cs | 5 +---- .../Operators/StartFuncObservable.cs | 5 +---- .../Operators/WhileObservable.cs | 6 +----- .../Advanced/SubscriptionScheduling.cs | 5 +---- .../Advanced/TaskSignalSubscription{T}.cs | 4 +--- .../Disposables/MultipleDisposableAsync.cs | 5 +---- .../Operators/Blend.cs | 12 ++---------- 13 files changed, 14 insertions(+), 65 deletions(-) diff --git a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs index c21bc9f8..b0cbd66b 100644 --- a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs @@ -11,12 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Delivers notifications on and keeps emissions at least -/// apart: a value arriving inside that window is deferred to the end of it and a -/// newer value takes the deferred one's place. Completion waits for a deferred value to land, while an error is -/// forwarded at once and discards it. -/// +/// Keeps emissions at least apart on ; completion waits for a deferred value, an error discards it. /// The type of elements in the source sequence. /// The source observable. /// The minimum period between emissions. diff --git a/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs b/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs index aa3b2dce..6eb5d64d 100644 --- a/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DebounceImmediateObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Emits the first source value inline, then holds each later value for and emits only the -/// most recent one. A terminal notification flushes a pending value before it is forwarded. -/// +/// Emits the first value inline, then the most recent value after of quiet. /// The type of elements in the source sequence. /// The source observable. /// The debounce duration. diff --git a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs index e7507d5f..47772429 100644 --- a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs @@ -10,11 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Forwards a value inline when holds for it, cancelling any pending emission, and -/// otherwise emits it once elapses with no newer value. An error or completion drops a -/// pending value. -/// +/// Emits a value inline when holds for it, otherwise after of quiet. /// The type of elements in the source sequence. /// The source observable. /// The debounce duration. diff --git a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs index c68c354c..5aadf57f 100644 --- a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs @@ -11,11 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Wraps each source value as an update and emits a staleness marker when passes -/// without one, measured from subscription. The window re-arms on every value, so a single quiet stretch yields one -/// marker. -/// +/// Wraps each value as an update and emits one staleness marker per quiet stretch of . /// The type of elements in the source sequence. /// The source observable. /// The period after which the sequence is considered stale. diff --git a/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs b/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs index 8ec4147d..0fad6fe9 100644 --- a/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ObserveOnIfObservable.cs @@ -11,12 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Schedules each source value onto or according to -/// the latest value from , using the false scheduler until the condition first emits. Error -/// and completion are forwarded on the producer's thread, so a terminal notification can overtake scheduled values, and -/// the condition's own error or completion is ignored. -/// +/// Schedules values per the latest value; terminal notifications bypass scheduling. /// The type of elements in the source sequence. /// The source observable. /// The reactive condition observable. diff --git a/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs b/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs index 3443f43b..e16f3a43 100644 --- a/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/RetryWithBackoffObservable.cs @@ -11,11 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Re-subscribes the source after an error, waiting the policy's initial delay scaled by its backoff factor for each -/// attempt and capped by its maximum, then forwards the error once the retry budget is spent. Every error reaches the -/// policy's callback, and values forwarded before a retry are not retracted. -/// +/// Re-subscribes after an error using the policy's backoff delays, forwarding the error once its budget is spent. /// The type of elements in the source sequence. /// The source observable. /// The retry / backoff configuration. diff --git a/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs b/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs index 88f5a838..d0b1ad5a 100644 --- a/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/StartActionObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Runs the action once per subscription, inline or on , then emits -/// and completes. An exception from the action is forwarded as an error instead. -/// +/// Runs the action once per subscription, then emits and completes. /// The action to run. /// An optional scheduler; null runs the action synchronously inline. internal sealed class StartActionObservable(Action action, ISequencer? scheduler) : IObservable diff --git a/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs b/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs index 315e0336..35b0e263 100644 --- a/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/StartFuncObservable.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Runs the function once per subscription, inline or on , then emits the result and -/// completes. An exception from the function is forwarded as an error instead. -/// +/// Runs the function once per subscription, then emits its result and completes. /// Result type. /// Function to run. /// Optional scheduler; null runs inline. diff --git a/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs b/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs index c22f6a14..2de29667 100644 --- a/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/WhileObservable.cs @@ -11,11 +11,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// -/// Loops the supplied on the supplied , or inline when no scheduler is -/// provided, emitting after each iteration for as long as -/// returns true. -/// +/// Invokes the action and emits per iteration while returns . /// The loop predicate. Evaluated before each iteration. /// The action to invoke per iteration. /// An optional scheduler; null runs every iteration inline. diff --git a/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs b/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs index 7d7f808d..98b6c117 100644 --- a/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs +++ b/src/Primitives.Shared/Advanced/SubscriptionScheduling.cs @@ -34,10 +34,7 @@ internal static IDisposable OnCurrentThread(TState state, Func - /// Runs on : inline for the immediate sequencer, and - /// queued behind the current-thread sequencer when that one is the target and is draining work. - /// + /// Runs inline on the immediate sequencer, otherwise schedules it on . /// The type of the state passed to the scheduled work. /// The sequencer that runs the work. /// The state carried to . diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs index 1d93ba50..4a5d7a63 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs @@ -18,9 +18,7 @@ public abstract class TaskSignalSubscription(IObserverAsync observer) : IA /// The cancellation token source that cancels the subscription's job on disposal. private readonly CancellationTokenSource _cts = new(); - /// Flows through the job's notification chain across thread hops, so a reentrant - /// issued from inside the job skips the self-join on - /// that would otherwise deadlock. + /// Set while the job runs, so a reentrant from inside it skips the self-join. private readonly AsyncLocal _executing = new(); /// Set on the first disposal so later calls are no-ops. diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs index 6400f71b..17b7c488 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs @@ -30,10 +30,7 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable /// The synchronization gate protecting all mutable state in this collection. private readonly Lock _gate = new(); - /// - /// Backing array, until something is added. A removal zeroes its slot rather than shifting - /// elements, so is the high-water mark and the non-null slots. - /// + /// Backing array; a removal zeroes its slot, so is the high-water mark and the non-null slots. private IAsyncDisposable?[]? _items; /// High-water mark of used slots in . Includes slots zeroed by Remove. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs index 81174018..dcbd4006 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs @@ -191,11 +191,7 @@ internal async ValueTask SubscribeSourcesAsync( await _outerDisposable.SetDisposableAsync(outerSubscription).ConfigureAwait(false); } - /// - /// Links the original subscribe-time cancellation token into this subscription's dispose chain so - /// later per-emission methods can rely on instead of - /// allocating a per-emission linked CTS. - /// + /// Links the subscribe-time token into this subscription's dispose chain, surfacing it as . /// The subscribe-time token. internal void LinkExternalCancellation(CancellationToken external) { @@ -559,11 +555,7 @@ internal void BeginSubscribing() => FireAndForgetHelper.Run(async () => } }); - /// - /// Links the original subscribe-time cancellation token into this subscription's dispose chain so - /// later per-emission methods can rely on instead of - /// allocating a per-emission linked CTS. - /// + /// Links the subscribe-time token into this subscription's dispose chain, surfacing it as . /// The subscribe-time token. internal void LinkExternalCancellation(CancellationToken external) { From 697d6d40eb25860d17c22d2d987285f2bffc4fc9 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:30:43 +1000 Subject: [PATCH 06/11] Further --- src/Directory.Packages.props | 1 - src/Polyfills/TaskPolyfillExtensions.cs | 9 +- .../Advanced/StartSubscription.cs | 20 +- src/Primitives.Async.Shared/AsyncContext.cs | 48 ++- .../Components/ReactiveComponentBase.cs | 7 +- .../Internal/ScheduledWait.cs | 41 +++ .../ObservableSubscriptionExtensions.cs | 18 +- .../Operators/BufferUntilIdleObservable.cs | 2 +- .../Operators/ConflateObservable.cs | 1 - .../Operators/ObserveOnObservable.cs | 2 - .../Operators/RunAllObservable.cs | 5 +- .../Operators/ScheduledSourceObservable.cs | 4 +- .../Operators/SyncTimerObservable.cs | 3 - .../Operators/ThrottleDistinctObservable.cs | 1 - .../Operators/UsingActionObservable.cs | 2 +- .../Operators/UsingFuncObservable.cs | 2 +- .../ReactiveExtensions.cs | 76 ++--- .../CoalescingDispatchScheduler.cs | 9 +- .../DispatchWorkItemBase.cs | 8 +- .../Advanced/AsyncSubscriptionLifetime.cs | 2 +- .../Advanced/AutoConnectSignal{T}.cs | 2 - .../Advanced/AutoShareSignal{T}.cs | 5 +- .../Advanced/CatchSignal{T}.cs | 2 +- .../Advanced/CreateSink{T}.cs | 7 +- src/Primitives.Shared/Advanced/EverySignal.cs | 3 +- .../Advanced/FromAsyncSubscription{T}.cs | 11 +- .../Advanced/FromAsyncTaskObservation{T}.cs | 2 +- ...Signal{TEventHandler,TCallback,TResult}.cs | 6 +- .../Advanced/IsEmptySignal{T}.cs | 1 - .../MaxConcurrentBlendCoordinator{T}.cs | 4 +- .../Advanced/MergeCoordinator{T}.cs | 1 - .../Advanced/RaceWitness{T}.cs | 2 +- .../Advanced/RecoverSignal{T,TException}.cs | 7 +- .../Advanced/ResumeSignal{T}.cs | 2 +- src/Primitives.Shared/Advanced/StartSignal.cs | 8 +- .../Advanced/StartSignal{T}.cs | 8 +- .../Advanced/SwitchWitness{T}.cs | 2 +- .../Advanced/TaskFaultObservation.cs | 25 ++ .../Advanced/WitnessOnSignal{T}.cs | 2 +- .../ConnectableSignalRxNameExtensions.cs | 10 +- src/Primitives.Shared/Core/Spark{T}.cs | 8 +- .../SignalOperatorMixins.BlendUnique.cs | 14 +- ...SignalOperatorMixins.ChooseSwitchSelect.cs | 11 +- .../SignalOperatorMixins.CollectionSignals.cs | 4 +- .../SignalOperatorMixins.CombineLatest.cs | 3 +- .../SignalOperatorMixins.Coordinators.cs | 2 +- .../SignalOperatorMixins.SchedulerSignals.cs | 194 ++++++----- .../SignalOperatorMixins.StatefulSignals.cs | 3 - .../SignalOperatorMixins.Switch.cs | 47 ++- ...alOperatorParityMixins.AggregateHelpers.cs | 2 - ...OperatorParityMixins.AwaitableTerminals.cs | 6 +- ...atorParityMixins.BooleanTerminalHelpers.cs | 2 - .../SignalOperatorParityMixins.FlatMap.cs | 1 - .../SignalOperatorParityMixins.Helpers.cs | 2 - .../SignalOperatorParityMixins.Probe.cs | 4 +- ...ixins.RxNames.CombineLatest.Collections.cs | 7 +- .../SignalOperatorParityMixins.RxNames.cs | 115 +++---- .../SignalOperatorParityMixins.cs | 12 +- .../Signals/PrioritySemaphoreSignal{T}.cs | 32 +- .../Signals/ReplaySignal{T}.cs | 1 - .../Signals/SignalExtensions{Create}.cs | 8 +- .../Signals/SignalExtensions{FromTask}.cs | 25 +- .../Signals/SignalExtensions{GetAwaiter}.cs | 10 +- .../Signals/Signal{Create}.cs | 14 +- .../Signals/Signal{Factories}.cs | 2 +- .../Signals/Signal{FromTask}.cs | 2 +- .../Signals/TaskSignal{T}.cs | 2 +- .../Signals/TaskTerminalCompletion{T}.cs | 3 - .../Disposables/CancellationDisposable.cs | 2 +- .../Disposables/EmptyDisposable.cs | 2 +- .../Disposables/MultipleDisposable.cs | 9 +- .../Disposables/MutableDisposable.cs | 8 +- .../Disposables/OnceDisposable.cs | 12 +- .../Disposables/SingleDisposable.cs | 3 +- .../SingleReplaceableDisposable.cs | 59 ++-- .../Disposables/SwapDisposable.cs | 10 +- .../Advanced/AsyncSerialGate.cs | 6 +- .../Advanced/BackgroundJobSignal{T}.cs | 4 +- .../Advanced/CallbackWitnessAsync.cs | 3 +- .../Advanced/FlatMapCoordinator{TResult}.cs | 12 +- .../Advanced/ForwardingWitnessAsync.cs | 5 +- .../Advanced/IntervalSignal.cs | 3 +- .../Advanced/IntervalSubscription.cs | 2 +- .../Advanced/LeadSubscription{T}.cs | 2 +- .../Advanced/PooledDelaySource.cs | 21 +- .../Advanced/ReattemptSubscription{T}.cs | 2 +- .../Advanced/SequenceSubscription.cs | 1 - .../Advanced/StartSubscription{TResult}.cs | 20 +- .../Advanced/SyncLatestCoordinatorBase.cs | 2 +- ...tEnumerableCoordinator{TSource,TResult}.cs | 4 +- .../Advanced/SyncLatestIndexedWitness.cs | 7 +- .../Advanced/SyncLatestLifecycle.cs | 11 +- .../Advanced/TakeUntilLifecycle.cs | 12 +- .../Advanced/TaskSignalSubscription{T}.cs | 8 +- .../ConnectableSignalAsyncHelper.cs | 22 +- .../Disposables/DisposableAsync.cs | 6 +- .../Disposables/DisposableAsyncSlot.cs | 20 +- .../Disposables/MultipleDisposableAsync.cs | 16 +- .../SingleReplaceableDisposableAsync.cs | 4 +- .../Helpers/FireAndForgetHelper.cs | 2 +- .../IObserverAsync.cs | 6 +- .../Observables/Create.cs | 2 - .../Observables/Empty.cs | 1 - .../Observables/Never.cs | 1 - .../Observables/Return.cs | 3 +- .../Observables/ToAsyncSignal.cs | 7 +- .../Operators/Blend.cs | 7 +- .../Operators/Chain.cs | 3 +- .../Operators/ChainEnumerableSignal{T}.cs | 4 +- .../Operators/ChainSignalSourcesSignal{T}.cs | 4 +- .../Operators/Expire.cs | 7 +- .../Operators/Fold.cs | 10 +- .../Operators/Keep.cs | 4 - .../Operators/Map.cs | 4 - .../Operators/Multicast.cs | 5 +- .../Operators/Pair.cs | 4 +- .../Operators/ParityHelpers.FilterFusions.cs | 2 - .../ParityHelpers.OperatorFusions.cs | 4 +- .../Operators/ParityHelpers.Partition.cs | 1 - .../Operators/ParityHelpers.cs | 8 +- .../Operators/Recover.cs | 2 - .../Operators/RefCount.cs | 1 - .../Operators/Shift.cs | 2 +- .../SignalAsyncExtensions.Delay.Rx.cs | 4 +- .../SignalAsyncExtensions.Timeout.Rx.cs | 20 +- .../Operators/StartWith.cs | 4 +- .../Operators/SubscribeAsync.cs | 2 +- .../Operators/SwitchToSignal.cs | 2 +- .../Operators/SyncLatest2.cs | 7 +- .../Operators/SyncLatestEnumerable.cs | 1 - .../Operators/TakeUntil.cs | 20 +- .../Operators/Throttle.cs | 20 +- .../Operators/Unique.cs | 13 +- ...yLatestSignalAsyncStateObserverLease{T}.cs | 4 +- ...yLatestSignalAsyncStateObserverLease{T}.cs | 4 +- ...currentStatelessReplayLatestSignalAsync.cs | 2 +- .../UnhandledExceptionHandler.cs | 9 +- .../WitnessAsync.cs | 19 +- .../Concurrency/AvaloniaScheduler.cs | 46 ++- .../Concurrency/AvaloniaScheduler.cs | 22 +- .../ReactiveUI.Primitives.Avalonia.csproj | 4 + .../Concurrency/BlazorRendererSequencer.cs | 69 ++-- .../Concurrency/BlazorRendererSequencer.cs | 72 ++-- .../ReactiveUI.Primitives.Blazor.csproj | 4 + .../Advanced/AggregateWitness.cs | 6 +- .../Advanced/AsyncEnumerableSignal{T}.cs | 8 +- .../Advanced/BufferWitness.cs | 5 +- .../Advanced/IAggregator.cs | 7 +- .../Advanced/SinkSubscription.cs | 6 +- .../Advanced/SinkTerminal.cs | 6 +- .../Advanced/SubscriptionSlots.cs | 5 +- .../Advanced/SynchronizeWitness.cs | 6 +- .../Advanced/UseSignal{TResource,T}.cs | 6 +- .../Advanced/WitnessTeardown.cs | 2 +- .../Concurrency/ScheduledItem.cs | 18 +- .../ConnectableSignal{T}.cs | 3 +- .../Core/TimeInterval{T}.cs | 5 +- src/ReactiveUI.Primitives.Core/Optional.cs | 5 +- .../Signals/AsyncSignal.cs | 1 - .../Signals/AwaitWitness.cs | 10 +- .../Signals/BehaviorSignalState{T}.cs | 2 +- .../Signals/Broadcaster{T}.cs | 120 ++++--- .../Signals/CommandSignal{TResult}.cs | 5 +- .../Signals/DelayableNotificationSignal{T}.cs | 6 +- .../Signals/DelegateWitness{T}.cs | 7 +- .../MapWithSignal{TSource,TState,TResult}.cs | 5 +- .../Signals/Signal{T}.cs | 4 +- .../Signals/StateSignal{T}.cs | 2 +- .../Signals/TapWithSignal{T,TState}.cs | 5 +- .../ConcurrencyLimiter.cs | 41 ++- .../Continuation.cs | 186 ++++++---- .../CurrentValueSubject.cs | 3 +- .../FirstAsValueTaskHelper.cs | 6 +- .../Heartbeat.cs | 7 +- .../Internal/ReduceSinkState.cs | 4 +- .../Operators/BinaryMinMaxObservable.cs | 9 +- .../Operators/BooleanReduceObservable.cs | 10 +- .../Operators/BufferUntilObservable.cs | 6 +- .../CatchAndReturnWithFactoryObservable.cs | 7 +- .../Operators/CatchIgnoreObservable.cs | 6 +- .../Operators/DropIfBusyObservable.cs | 6 +- .../Operators/FilterRegexObservable.cs | 5 +- .../FirstMatchFromCandidatesObservable.cs | 11 +- .../Operators/MinMaxObservable.cs | 10 +- .../Operators/PartitionObservable.cs | 9 +- .../ReplayLastOnSubscribeObservable.cs | 5 +- .../Operators/SampleLatestObservable.cs | 7 +- .../Operators/ScanWithInitialObservable.cs | 5 +- .../SelectAsyncConcurrentObservable.cs | 7 +- .../SelectAsyncSequentialObservable.cs | 7 +- .../Operators/SelectConstantObservable.cs | 2 +- .../Operators/SelectLatestAsyncObservable.cs | 17 +- .../Operators/SelectManyThenObservable.cs | 7 +- .../Operators/ShuffleObservable.cs | 33 +- .../Operators/SubscribeAsyncObservable.cs | 9 +- .../Operators/SwitchIfEmptyObservable.cs | 6 +- .../Operators/SynchronizeAsyncObservable.cs | 8 +- .../Operators/TakeUntilInclusiveObservable.cs | 7 +- .../Operators/TrySelectObservable.cs | 2 +- .../Operators/WaitUntilObservable.cs | 4 +- .../Stale.cs | 10 +- .../TimerSinkState.cs | 6 +- .../Concurrency/MauiDispatcherSequencer.cs | 6 +- .../Concurrency/MauiDispatcherSequencer.cs | 7 +- .../ActivationExtensionsEmitter.cs | 1 - .../CodeGeneration/GeneratedNames.cs | 3 +- .../CodeGeneration/PooledStringBuilder.cs | 1 - .../EventGenerator.cs | 4 +- .../Helpers/InstanceTargetExtractor.cs | 2 +- .../Helpers/SymbolHelpers.cs | 3 +- .../Helpers/TargetCollections.cs | 1 - .../Concurrency/WasmScheduler.cs | 71 ++-- .../Disposables/ContainerDisposable.cs | 10 +- .../LinqExtensions.ContainerDisposable.cs | 2 +- .../Platforms/android/HandlerSequencer.cs | 27 +- .../Platforms/apple/NSRunloopSequencer.cs | 26 +- .../Concurrency/ControlSequencer.cs | 51 ++- .../Concurrency/ControlSequencer.cs | 66 ++-- .../Properties/AssemblyInfo.cs | 7 + .../Concurrency/DispatcherQueueSequencer.cs | 44 ++- .../Concurrency/DispatcherQueueSequencer.cs | 31 +- .../Concurrency/DispatcherSequencer.cs | 47 ++- .../Concurrency/DispatcherSequencer.cs | 22 +- .../Advanced/DispatchSequencerState.cs | 6 +- .../Concurrency/CurrentThreadSequencer.cs | 6 +- .../Concurrency/Sequencer.Simple.cs | 11 +- .../Concurrency/Sequencer.cs | 2 + .../SynchronizationContextSequencer.cs | 14 +- .../Concurrency/TaskPoolSequencer.cs | 24 +- .../Concurrency/ThreadPoolSequencer.cs | 22 +- .../VirtualTimeSequencerExtensions.cs | 1 - ...rtualTimeSequencer{TAbsolute,TRelative}.cs | 6 +- .../VirtualTimeState{TAbsolute,TRelative}.cs | 1 - .../Concurrency/WasmSequencer.cs | 28 +- .../Platforms/android/HandlerSequencer.cs | 16 +- .../Platforms/apple/NSRunloopSequencer.cs | 15 +- src/ReactiveUI.Primitives/RxVoid.cs | 4 +- .../BooleanSignalWitness.cs | 2 +- .../IntR3ActionWitness.cs | 2 +- .../SingleReplaceableDisposableTests.cs | 73 ++++ .../AsyncRenameCoverageTests.cs | 4 +- .../CancellationStopCoordinatorTests.cs | 50 +++ .../ContextSwitchSignalAsyncTests.cs | 263 ++++++++++++++ .../CustomTaskScheduler.cs | 63 +++- .../ErrorHandlingOperatorTests.cs | 10 +- .../FactorySignalTests.cs | 2 +- .../IntervalSubscriptionTests.cs | 72 ++++ .../ManualTaskScheduler.cs | 27 ++ .../ObserveOnAsyncSignalTests.cs | 322 ------------------ .../ObserveOnIfOperatorTests.cs | 20 +- .../ObserveOnSafeOperatorTests.cs | 24 +- .../StartSubscriptionTests.cs | 105 ++++++ .../TransformationOperatorTests.cs | 62 +--- .../AvaloniaSchedulerTests.cs | 200 ++++++----- .../AvaloniaTestSession.cs | 21 -- ....Primitives.Avalonia.Reactive.Tests.csproj | 1 - .../AvaloniaSchedulerTests.cs | 200 ++++++++--- .../AvaloniaTestSession.cs | 21 -- ...eactiveUI.Primitives.Avalonia.Tests.csproj | 1 - .../BlazorRendererSequencerTests.cs | 117 ++++++- .../BlazorRendererSequencerTests.cs | 117 ++++++- .../ContinuationTests.cs | 287 ++++++++++++---- .../Internal/ScheduledWaitTests.cs | 37 ++ .../WasmSchedulerTests.cs | 28 ++ .../BroadcasterTests.cs | 85 +++++ .../PrioritySemaphoreSignalTests.cs | 33 ++ .../RxNamesTests.Scheduling.cs | 128 ++++--- .../RxNamesTests.cs | 2 +- .../ScheduledItemTests.cs | 40 +++ .../SequencerTests.Cancellation.cs | 19 +- .../SignalFactoriesTests.DefaultSequencer.cs | 24 +- .../SignalTests.cs | 2 +- .../StartSignalTests.cs | 79 ++++- .../SwitchCoordinatorTests.cs | 178 ++++++++++ .../SwitchWitnessTests.cs | 105 ------ .../TaskPoolSequencerTests.cs | 40 +++ .../ControlSequencerTests.cs | 227 +++++++++--- .../ControlSequencerTests.cs | 210 +++++++++--- .../DispatcherQueueSequencerTests.cs | 185 +++++++--- ...eUI.Primitives.WinUI.Reactive.Tests.csproj | 13 +- .../DispatcherQueueSequencerTests.cs | 197 +++++++---- .../ReactiveUI.Primitives.WinUI.Tests.csproj | 13 +- .../DispatcherSequencerTests.cs | 156 ++++++--- .../DispatcherSequencerTests.cs | 176 ++++++---- 284 files changed, 4414 insertions(+), 2587 deletions(-) create mode 100644 src/Primitives.Extensions.Shared/Internal/ScheduledWait.cs create mode 100644 src/Primitives.Shared/Advanced/TaskFaultObservation.cs create mode 100644 src/ReactiveUI.Primitives.WinForms/Properties/AssemblyInfo.cs create mode 100644 src/tests/ReactiveUI.Disposables.Tests/SingleReplaceableDisposableTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/CancellationStopCoordinatorTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ContextSwitchSignalAsyncTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ManualTaskScheduler.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/StartSubscriptionTests.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaTestSession.cs delete mode 100644 src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaTestSession.cs create mode 100644 src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ScheduledWaitTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/ScheduledItemTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/SwitchCoordinatorTests.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 26e11fed..92c03c56 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -33,7 +33,6 @@ - diff --git a/src/Polyfills/TaskPolyfillExtensions.cs b/src/Polyfills/TaskPolyfillExtensions.cs index c753f8cf..ce028c0d 100644 --- a/src/Polyfills/TaskPolyfillExtensions.cs +++ b/src/Polyfills/TaskPolyfillExtensions.cs @@ -58,7 +58,7 @@ private static async Task WaitForCompletionAsync(Task task, TimeSpan timeout, Ca using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); if (timeout != Timeout.InfiniteTimeSpan) { - linked.CancelAfter(timeout); + ScheduleTimeout(linked, timeout); } using (linked.Token.Register(static state => ((TaskCompletionSource)state).TrySetResult(true), signal)) @@ -71,5 +71,12 @@ private static async Task WaitForCompletionAsync(Task task, TimeSpan timeout, Ca } } } + + /// Schedules cancellation when the timeout elapses. + /// The cancellation source. + /// The timeout duration. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static void ScheduleTimeout(CancellationTokenSource source, TimeSpan timeout) => source.CancelAfter(timeout); } #endif diff --git a/src/Primitives.Async.Shared/Advanced/StartSubscription.cs b/src/Primitives.Async.Shared/Advanced/StartSubscription.cs index 88b5c2f5..02bcb33b 100644 --- a/src/Primitives.Async.Shared/Advanced/StartSubscription.cs +++ b/src/Primitives.Async.Shared/Advanced/StartSubscription.cs @@ -46,7 +46,21 @@ protected override async ValueTask ExecuteAsyncCore( return; } - await Task.Factory.StartNew( + await ExecuteOnSchedulerAsync(observer, taskScheduler, cancellationToken).ConfigureAwait(false); + } + + /// Starts the action through the supplied scheduler. + /// The observer receiving the signal notification. + /// The scheduler that starts the action. + /// Cancellation for the scheduled task and notifications. + /// The scheduled action and notification operation. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private Task ExecuteOnSchedulerAsync( + IObserverAsync observer, + TaskScheduler taskScheduler, + CancellationToken cancellationToken) => + Task.Factory.StartNew( static s => { var (self, observer, cancellationToken) = @@ -57,9 +71,7 @@ await Task.Factory.StartNew( cancellationToken, TaskCreationOptions.DenyChildAttach, taskScheduler) - .Unwrap() - .ConfigureAwait(false); - } + .Unwrap(); /// Runs the action and forwards the completion signal. /// The observer receiving the signal notification. diff --git a/src/Primitives.Async.Shared/AsyncContext.cs b/src/Primitives.Async.Shared/AsyncContext.cs index b95d9352..f9dd4046 100644 --- a/src/Primitives.Async.Shared/AsyncContext.cs +++ b/src/Primitives.Async.Shared/AsyncContext.cs @@ -26,7 +26,7 @@ private AsyncContext() public static AsyncContext Default { get; } = new(); /// Gets the synchronization context to use for marshaling callbacks and continuations. - /// A specified synchronization context receives posted continuations; otherwise the task scheduler determines execution. + /// A synchronization context takes precedence over the task scheduler and sequencer. public SynchronizationContext? SynchronizationContext { get; init; } /// Gets the task scheduler to use for scheduling tasks, or null to use the default scheduler. @@ -67,8 +67,7 @@ public static AsyncContext From(TaskScheduler taskScheduler) /// The sequencer to use for configuring the AsyncContext. /// An AsyncContext instance configured with the provided scheduler. /// Thrown if scheduler is null. - /// If the provided sequencer directly implements , that instance is used - /// directly. Otherwise, continuations are scheduled as direct instances on the sequencer. + /// A sequencer that is also a synchronization context receives posted continuations. public static AsyncContext From(ISequencer scheduler) { ArgumentExceptionHelper.ThrowIfNull(scheduler); @@ -122,7 +121,7 @@ public readonly record struct AsyncContextSwitcherAwaitable( /// Schedules the specified continuation action to be invoked when the operation has completed. /// The action to execute when the operation is complete. Cannot be null. - /// Continuations use the synchronization context or task scheduler; cancellation invokes them immediately on the current thread. + /// An already cancelled token invokes the continuation immediately on the caller's thread. public void OnCompleted(Action continuation) { ArgumentExceptionHelper.ThrowIfNull(continuation); @@ -136,18 +135,14 @@ public void OnCompleted(Action continuation) var sc = AsyncContext.SynchronizationContext; if (sc is not null) { - sc.Post(static c => ((Action)c!).Invoke(), continuation); + PostContinuation(sc, continuation); return; } var ts = AsyncContext.TaskScheduler; if (ts is not null && ts != TaskScheduler.Default) { - _ = Task.Factory.StartNew( - continuation, - CancellationToken.None, - TaskCreationOptions.DenyChildAttach, - ts); + ScheduleContinuation(ts, continuation); return; } @@ -158,13 +153,36 @@ public void OnCompleted(Action continuation) return; } - // Queue directly to avoid allocating a Task for each continuation. - if (ts is null || ts == TaskScheduler.Default) - { - _ = ThreadPool.UnsafeQueueUserWorkItem(static c => ((Action)c!).Invoke(), continuation); - } + QueueContinuation(continuation); } + /// Posts a continuation to the supplied synchronization context. + /// The context receiving the continuation. + /// The continuation to invoke. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void PostContinuation(SynchronizationContext context, Action continuation) => + context.Post(static c => ((Action)c!).Invoke(), continuation); + + /// Schedules a continuation through the supplied task scheduler. + /// The scheduler receiving the continuation. + /// The continuation to invoke. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ScheduleContinuation(TaskScheduler scheduler, Action continuation) => + _ = Task.Factory.StartNew( + continuation, + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + scheduler); + + /// Queues a continuation on the thread pool without capturing execution context. + /// The continuation to invoke. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void QueueContinuation(Action continuation) => + _ = ThreadPool.UnsafeQueueUserWorkItem(static c => ((Action)c!).Invoke(), continuation); + /// Work item used to schedule context-switch continuations directly on an . /// The continuation to invoke. private sealed class ContinuationWorkItem(Action continuation) : IWorkItem diff --git a/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs b/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs index 5bd07e31..83b409fa 100644 --- a/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs +++ b/src/Primitives.Blazor.Shared/Components/ReactiveComponentBase.cs @@ -12,9 +12,10 @@ namespace ReactiveUI.Primitives.Blazor.Components; #endif /// Base component that tracks reactive subscriptions and refreshes through Blazor's renderer dispatcher. -/// Every observed callback is marshalled onto the renderer dispatcher, so a handler can touch component state -/// directly, and a callback that throws is dispatched to the enclosing error boundary instead of faulting a detached -/// task. Disposing the component disposes every tracked subscription. +/// +/// Callbacks run on the renderer dispatcher; callback exceptions reach the enclosing error boundary. Component disposal releases tracked +/// subscriptions. +/// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public class ReactiveComponentBase : ComponentBase, IDisposable { diff --git a/src/Primitives.Extensions.Shared/Internal/ScheduledWait.cs b/src/Primitives.Extensions.Shared/Internal/ScheduledWait.cs new file mode 100644 index 00000000..a968843c --- /dev/null +++ b/src/Primitives.Extensions.Shared/Internal/ScheduledWait.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +#if REACTIVE_SHIM +namespace ReactiveUI.Primitives.Extensions.Reactive.Internal; +#else +namespace ReactiveUI.Primitives.Extensions.Internal; +#endif + +/// Runs an action after a synchronous wait. +internal static class ScheduledWait +{ + /// Runs an action after the specified delay. + /// The delay. + /// The action to invoke. + /// An empty disposable after the action returns. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static IDisposable Run(TimeSpan dueTime, Action action) => Run(dueTime, action, Wait); + + /// Invokes an action after the supplied wait completes. + /// The delay passed to the wait operation. + /// The action to invoke. + /// The wait operation; a failure prevents the action. + /// An empty disposable after the action returns. + internal static IDisposable Run(TimeSpan dueTime, Action action, Action wait) + { + wait(dueTime); + action(); + return EmptyDisposable.Instance; + } + + /// Blocks the current thread for the requested delay. + /// The delay. + [ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Wait(TimeSpan dueTime) => Thread.Sleep(dueTime); +} diff --git a/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs b/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs index 071cfb63..c4689aaf 100644 --- a/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs +++ b/src/Primitives.Extensions.Shared/ObservableSubscriptionExtensions.cs @@ -21,7 +21,7 @@ public static class ObservableSubscriptionExtensions /// The observable to subscribe to. extension(IObservable source) { - /// Subscribes to a -producing observable, discarding the value. Safe only when the sequence terminates synchronously. + /// Subscribes to a synchronously terminating sequence and discards its value. public void SubscribeAndComplete() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -127,7 +127,7 @@ public void WaitForCompletion(ISequencer scheduler, TimeSpan timeout) => public T? WaitForValue(ISequencer scheduler, TimeSpan timeout) => WaitForValueCore(source, scheduler, timeout); - /// Blocks until the source terminates; returns any captured error (does NOT rethrow). Default 30s timeout. + /// Blocks until the source terminates or the timeout expires, returning any captured error without throwing it. /// The captured error, or if completion was normal. [MethodImpl(MethodImplOptions.AggressiveInlining)] public Exception? WaitForError() => @@ -175,7 +175,7 @@ public void WaitForCompletion(ISequencer scheduler, TimeSpan timeout) => BlockingValueWitness sink = new(done); using var subscription = ScheduledSubscribe(source, sink, scheduler); - if (!done.Wait(timeout)) + if (!WaitForTerminal(done, timeout)) { throw new TimeoutException( $"WaitForValue timed out after {timeout.TotalSeconds}s."); @@ -197,7 +197,7 @@ private static void WaitForCompletionCore(IObservable source, ISequencer BlockingTerminalWitness sink = new(done); using var subscription = ScheduledSubscribe(source, sink, scheduler); - if (!done.Wait(timeout)) + if (!WaitForTerminal(done, timeout)) { throw new TimeoutException( $"WaitForCompletion timed out after {timeout.TotalSeconds}s."); @@ -226,7 +226,7 @@ private static void WaitForCompletionCore(IObservable source, ISequencer BlockingTerminalWitness sink = new(done); using var subscription = ScheduledSubscribe(source, sink, scheduler); - if (!done.Wait(timeout)) + if (!WaitForTerminal(done, timeout)) { throw new TimeoutException( $"WaitForError timed out after {timeout.TotalSeconds}s."); @@ -235,6 +235,14 @@ private static void WaitForCompletionCore(IObservable source, ISequencer return sink.Error; } + /// Blocks until a terminal signal arrives or the timeout expires. + /// The terminal signal. + /// The maximum wait duration. + /// True when the signal arrives; false on timeout. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool WaitForTerminal(ManualResetEventSlim done, TimeSpan timeout) => done.Wait(timeout); + /// Subscribes to on , or inline when no scheduler is supplied. /// The type of the elements in . /// The observable to subscribe to. diff --git a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs index 9c31fd7d..e0f494f1 100644 --- a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Buffers elements and emits them when the stream has been idle for a specified duration. Backs both the BufferUntilIdle and BufferUntilInactive public operators. +/// Buffers elements until the source has been idle for the specified duration. /// The type of elements in the source sequence. /// The source observable. /// The duration of inactivity required to flush the buffer. diff --git a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs index b0cbd66b..666bb875 100644 --- a/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ConflateObservable.cs @@ -128,7 +128,6 @@ void IDrainTarget.Drain() default: { - // Completed is the only remaining kind; this arm keeps the switch exhaustive. ForwardCompleted(); return; } diff --git a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs index 0c8eae16..6045ef6d 100644 --- a/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ObserveOnObservable.cs @@ -23,7 +23,6 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(scheduler); ArgumentExceptionHelper.ThrowIfNull(observer); - // The immediate sequencer runs scheduled work inline, so queue-and-drain would change nothing. if (ReferenceEquals(scheduler, Sequencer.Immediate)) { return source.Subscribe(observer); @@ -103,7 +102,6 @@ void IDrainTarget.Drain() default: { - // Completed is the only remaining kind; this arm keeps the switch exhaustive. _state.Terminate(); _downstream.OnCompleted(); return; diff --git a/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs b/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs index e7b05b55..461eb0cb 100644 --- a/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/RunAllObservable.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Extensions.Reactive.Operators; namespace ReactiveUI.Primitives.Extensions.Operators; #endif -/// Runs sources sequentially, ignores their values, and emits RxVoid on completion. Empty input completes immediately; source errors propagate. +/// Runs sources sequentially and emits RxVoid on completion, propagating source errors and completing immediately for empty input. /// The list of one-shot observables to run in order. internal sealed class RunAllObservable(IReadOnlyList> sources) : IObservable { @@ -56,7 +56,7 @@ internal sealed class Sink( /// public void OnNext(RxVoid value) { - // Ignore — we only care about completion. + // Values are ignored; completion advances to the next source. } /// @@ -80,7 +80,6 @@ public void OnCompleted() if (_looping) { - // Inside the loop the surrounding RunNext reads _iterationTerminated; no recursion. Volatile.Write(ref _iterationTerminated, 1); return; } diff --git a/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs b/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs index 48edde8c..8c0a4e12 100644 --- a/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ScheduledSourceObservable.cs @@ -108,13 +108,13 @@ public void OnNext(T value) /// public void OnError(Exception error) { - // Not forwarded: this operator is next-only by contract. + // Terminal notifications are not forwarded. } /// public void OnCompleted() { - // Not forwarded: this operator is next-only by contract. + // Terminal notifications are not forwarded. } } } diff --git a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs index 4681e65b..41a531a6 100644 --- a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs @@ -85,14 +85,11 @@ private void Remove(IObserver observer) { lock (_gate) { - // Never null: Dispose's Interlocked guard admits one Remove per subscription, and the - // observer was added under this same lock before the disposable was handed out. var updated = ObserverArrayHelpers.RemoveOrNull(_observers, observer, _emptyObservers)!; Volatile.Write(ref _observers, updated); if (ReferenceEquals(updated, _emptyObservers)) { - // Never null: reaching an empty set means Subscribe ran, which arms the timer. _timerSubscription!.Dispose(); _timerSubscription = null; } diff --git a/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs b/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs index 17a3ee90..f74fb9a6 100644 --- a/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/ThrottleDistinctObservable.cs @@ -27,7 +27,6 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(scheduler); ArgumentExceptionHelper.ThrowIfNull(observer); - // Equivalent to DistinctUntilChanged().Throttle(throttle, scheduler).DistinctUntilChanged(), fused into one sink. ThrottleDistinctSink sink = new(observer, throttle, scheduler); var subscription = source.Subscribe(sink); return new DisposableBag(subscription, sink); diff --git a/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs b/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs index cdc2e1e2..b19c2a43 100644 --- a/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/UsingActionObservable.cs @@ -67,7 +67,7 @@ private static void Run(IObserver observer, T resource, Action? actio } catch { - // Swallow secondary dispose failure; the primary exception is what callers care about. + // Secondary disposal errors do not replace the original failure. } observer.OnError(error); diff --git a/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs b/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs index 3f752c7e..fd6cc45c 100644 --- a/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/UsingFuncObservable.cs @@ -70,7 +70,7 @@ private static void Run(IObserver observer, T resource, Func CatchReturnUnit() => /// The source observable sequence. extension(IObservable source) { - /// Returns only values that are not null. Converts the nullability. + /// Filters out null values and returns a sequence with a non-nullable element type. /// A non nullable version of the observable that only emits valid values. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable WhereIsNotNull() => new WhereIsNotNullObservable(source); - /// Change the source observable type to . This allows us to be notified when the observable emits a value. + /// Replaces each source value with RxVoid. /// The signal. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable AsSignal() => @@ -189,11 +189,7 @@ public IObservable CatchIgnore(Action errorAction) public IObservable> DetectStale(TimeSpan stalenessPeriod, ISequencer scheduler) => new DetectStaleObservable(source, stalenessPeriod, scheduler); - /// - /// Applies a conflation algorithm to an observable stream. Anytime the stream OnNext twice - /// below minimumUpdatePeriod, the second update gets delayed to respect the - /// minimumUpdatePeriod. If more than 2 updates happen, only the last update is pushed. - /// + /// Emits updates at least minimumUpdatePeriod apart, retaining only the latest pending update. /// Minimum delay between two updates. /// Scheduler to publish updates. /// The conflated stream. @@ -216,7 +212,7 @@ public IObservable> Heartbeat(TimeSpan heartbeatPeriod, ISequencer public IObservable LatestOrDefault(T defaultValue) => new LatestOrDefaultObservable(source, defaultValue); - /// Logs the errors. Inline error logging without terminating the stream. + /// Logs errors without terminating the sequence. /// The logger. /// A sequence that logs errors. public IObservable LogErrors(Action logger) @@ -398,11 +394,7 @@ public IDisposable SubscribeSynchronous(Func onNext) => public IObservable SwitchIfEmpty(IObservable fallback) => new SwitchIfEmptyObservable(source, fallback); - /// - /// Synchronizes the asynchronous operations in downstream operations. - /// Use SubscribeSynchronus instead for a simpler version. - /// Call Sync.Dispose() to release the lock in the downstream methods. - /// + /// Pairs each value with a disposable acknowledgement that releases its downstream wait. /// An Observable of T and a release mechanism. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable<(T Value, IDisposable Sync)> SynchronizeAsync() => @@ -531,13 +523,7 @@ public IObservable ThrottleOnScheduler(TimeSpan timeSpan, ISequencer schedule [MethodImpl(MethodImplOptions.AggressiveInlining)] public Task ToHotTask() => FirstAsTaskHelper.FirstAsTask(source); - /// - /// Convert an observable to a that starts immediately. Backed by a - /// pooled implementation, so - /// steady-state callers pay no allocations after the per-type pool warms up. Prefer this over - /// when the call site can consume a - /// (single await, no caching, no WhenAll). - /// + /// Subscribes immediately and returns a ValueTask for the first value that must be consumed exactly once. /// A that completes with the first value, faults on source error, or faults on empty completion. [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask ToHotValueTask() => @@ -692,11 +678,7 @@ public IObservable DoOnSubscribe(Action action) => public IObservable DoOnDispose(Action disposeAction) => new DoOnDisposeObservable(source, disposeAction); - /// - /// Fused Where(predicate).Select(selector). Allocates a single observer - /// per subscription instead of two, eliminating the intermediate operator that - /// the equivalent Rx chain would build. - /// + /// Projects source values that satisfy the predicate. /// The projected element type. /// Filter applied to each source element. /// Projection applied to elements that pass . @@ -712,7 +694,7 @@ public IObservable WhereSelect(Func predicate, Func CatchReturn(T fallback) => new CatchReturnObservable(source, fallback); - /// Projects every source element to a stored constant, avoiding the closure allocation of .Select(_ => value). Common in fire-then-return-value chains. + /// Replaces each source value with the supplied constant. /// The result element type. /// The constant value emitted for each source element. /// An observable that emits for each source element. @@ -720,11 +702,7 @@ public IObservable CatchReturn(T fallback) => public IObservable SelectConstant(TResult constant) => new SelectConstantObservable(source, constant); - /// - /// Applies and emits only non-null results. - /// Replaces .Select(f).Where(x => x is not null).Select(x => x!) - /// with a single operator allocation. - /// + /// Projects source values and forwards only non-null results. /// The projected element type. /// Projection that may return . /// An observable that emits only non-null projected values. @@ -732,7 +710,7 @@ public IObservable SelectConstant(TResult constant) => public IObservable TrySelect(Func selector) => new TrySelectObservable(source, selector); - /// Chains two one-shot SelectMany projections into a single operator. Replaces .SelectMany(a).SelectMany(b) (2 operator allocations) with 1. + /// Projects each source value through two successive observable selectors. /// The intermediate element type. /// The final result type. /// First projection: source → intermediate observable. @@ -795,8 +773,8 @@ public IObservable GetMin(params IObservable[] sources) } /// Null-skipping operators for an observable source sequence of reference types. - /// The type. - /// The source. + /// The reference type of the source elements. + /// The source observable. extension(IObservable source) where T : class { @@ -904,12 +882,7 @@ public void OnNext(params T[] events) /// The observables to run in order. extension(IReadOnlyList> sources) { - /// - /// Runs a list of one-shot sequentially and emits - /// a single when all have completed. Replaces - /// .Concat().LastOrDefaultAsync() with a single operator that avoids stack - /// overflow on inline-completing sources. - /// + /// Runs sources sequentially and emits RxVoid after all complete. /// A one-shot observable that completes after all sources. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable RunAll() => @@ -922,11 +895,7 @@ public IObservable RunAll() => extension(IReadOnlyList candidates) { /// - /// Walks a list of candidate keys sequentially, projects each into a one-shot - /// observable, transforms the raw value, and emits the first transformed value - /// that satisfies . Errors from individual projections - /// are swallowed (the candidate is skipped). If no candidate matches, emits - /// . + /// Projects candidates sequentially, skipping projection errors, and emits the first matching transformed value or the fallback. /// /// The raw element type emitted by the projection. /// The transformed result type. @@ -977,14 +946,9 @@ public IDisposable ScheduleSafe(TimeSpan dueTime, Action action) { ArgumentExceptionHelper.ThrowIfNull(action); - if (scheduler is null) - { - Thread.Sleep(dueTime); - action(); - return EmptyDisposable.Instance; - } - - return scheduler.Schedule(dueTime, action); + return scheduler is null + ? ScheduledWait.Run(dueTime, action) + : scheduler.Schedule(dueTime, action); } } @@ -1054,8 +1018,8 @@ public IObservable ToPropertyObservable( } /// Scheduling operators for a single value. - /// The type. - /// The value. + /// The value type. + /// The value to schedule. extension(T value) { /// Schedules a single value after a delay. @@ -1138,7 +1102,7 @@ public static IObservable Start(Func function, ISeque new StartFuncObservable(function, scheduler); /// Builds a current-value subject pair: a read-only observable and the push-side observer. - /// The type. + /// The value type carried by the subject. /// The initial value. /// A tuple of IObservable and IObserver. public static (IObservable Observable, IObserver Observer) ToReadOnlyBehavior(T initialValue) diff --git a/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs b/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs index 43fd7b74..dcb51949 100644 --- a/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs +++ b/src/Primitives.Platform.Reactive.Shared/CoalescingDispatchScheduler.cs @@ -10,11 +10,10 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// Base for UI-thread dispatchers that drains queued work one batch per dispatcher post. -/// Work runs on the dispatcher thread; scheduling from that thread queues the item for the next posted batch -/// rather than running it inline, so a scheduled action never re-enters the caller. The disposable a -/// Schedule overload returns suppresses work that has not started and disposes what a started action -/// returned. A platform scheduler supplies and may replace the delayed path by overriding -/// . +/// +/// Work runs in posted dispatcher batches without inline reentrancy. Disposing a scheduled action suppresses unstarted work and disposes the +/// resource returned by an action that has started. +/// [System.Diagnostics.DebuggerDisplay("CoalescingDispatchScheduler: ReadyCount = {_readyCount}, DrainPosted = {_drainPosted}")] public abstract class CoalescingDispatchScheduler : LocalScheduler { diff --git a/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs b/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs index 5fb7f74c..0e881841 100644 --- a/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs +++ b/src/Primitives.Platform.Reactive.Shared/DispatchWorkItemBase.cs @@ -60,12 +60,18 @@ public void Run() return; } + ReleaseCanceledResult(); + } + + /// Releases the published result if the work item is cancelled. + internal void ReleaseCanceledResult() + { if (!IsDisposed) { return; } - disposable.Dispose(); + ReleaseStartedWork(); } /// Atomically claims the single cancellation transition for this work item. diff --git a/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs b/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs index 020b85ba..3e169c0b 100644 --- a/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs +++ b/src/Primitives.Shared/Advanced/AsyncSubscriptionLifetime.cs @@ -100,7 +100,7 @@ private static void CancelIgnoringDisposed(CancellationTokenSource cts) } catch (ObjectDisposedException) { - // Completion can release the CTS concurrently; disposal continues with the inner subscription. + // Concurrent completion does not prevent subscription disposal. } } } diff --git a/src/Primitives.Shared/Advanced/AutoConnectSignal{T}.cs b/src/Primitives.Shared/Advanced/AutoConnectSignal{T}.cs index db73a391..e0281569 100644 --- a/src/Primitives.Shared/Advanced/AutoConnectSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/AutoConnectSignal{T}.cs @@ -60,8 +60,6 @@ public IDisposable Subscribe(IObserver observer) var subscription = Source.Subscribe(observer); - // Auto-connect only ever counts up and connects once, so no lock is needed: a single - // CompareExchange latches the connect once the threshold is reached. var count = Interlocked.Increment(ref _count); if (count >= SubscriberCount && Interlocked.CompareExchange(ref _connected, 1, 0) == 0) { diff --git a/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs b/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs index cef1b153..38e83db0 100644 --- a/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/AutoShareSignal{T}.cs @@ -102,8 +102,6 @@ private void ConnectOutsideGate(IDisposable subscription) { _isConnecting = false; - // _connection is null here: _isConnecting gated every other subscriber out of Connect, and - // Release only ever nulls _connection. Publish the connection while subscribers remain. if (_count != 0) { _connection = connection; @@ -111,8 +109,7 @@ private void ConnectOutsideGate(IDisposable subscription) } } - // A re-entrant or concurrent Release drained the count while connecting, so the connection is - // orphaned and disposed here. + // A connection completed after the last unsubscription is disposed. connection.Dispose(); } diff --git a/src/Primitives.Shared/Advanced/CatchSignal{T}.cs b/src/Primitives.Shared/Advanced/CatchSignal{T}.cs index 66e650ed..740569a6 100644 --- a/src/Primitives.Shared/Advanced/CatchSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/CatchSignal{T}.cs @@ -171,7 +171,7 @@ private void RecursiveRun(Action self) } } - /// Advances the enumerator to the next source. Call while holding . + /// Advances the enumerator to the next source while the caller holds the gate. /// The next source, or once the sequence is exhausted. /// The exception the sequence raised, when it raised one. /// when the sequence advanced without raising. diff --git a/src/Primitives.Shared/Advanced/CreateSink{T}.cs b/src/Primitives.Shared/Advanced/CreateSink{T}.cs index 234c4735..ca68e7fb 100644 --- a/src/Primitives.Shared/Advanced/CreateSink{T}.cs +++ b/src/Primitives.Shared/Advanced/CreateSink{T}.cs @@ -10,12 +10,9 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// -/// The observer handed to create-style subscription factories. It forwards notifications until terminated and owns -/// the cancel resource the factory returns; when constructed with disposeOnNextThrow it also releases that -/// resource before rethrowing if a downstream OnNext throws. -/// +/// Forwards notifications until termination and owns the resource returned by the subscription factory. /// The value type. +/// When disposeOnNextThrow is enabled, a downstream OnNext failure disposes the resource before rethrowing. [System.Diagnostics.DebuggerDisplay("CreateSink: Stopped = {_stopped}, Observer = {_observer}")] public sealed class CreateSink : IDisposable, IObserver { diff --git a/src/Primitives.Shared/Advanced/EverySignal.cs b/src/Primitives.Shared/Advanced/EverySignal.cs index 458ea6ca..a958d980 100644 --- a/src/Primitives.Shared/Advanced/EverySignal.cs +++ b/src/Primitives.Shared/Advanced/EverySignal.cs @@ -99,7 +99,6 @@ internal EveryCoordinator Run() /// Emits the current tick and reschedules unless cancelled. private void Tick() { - // The sequencer checks cancellation before invoking this item, not before the observer is called. if (_slot.IsDisposed) { return; @@ -109,7 +108,7 @@ private void Tick() _tick++; _observer.OnNext(tick); - // An observer that disposes from inside OnNext must stop the schedule rather than re-arm it. + // Disposal from OnNext stops future notifications. if (_slot.IsDisposed) { return; diff --git a/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs b/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs index 8bf1e500..5ac15f6f 100644 --- a/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs +++ b/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs @@ -101,14 +101,21 @@ internal IDisposable Start() } FromAsyncTaskObservation observation = new(Observer, Lifetime, ExternalCancellation, linkedSource); + RegisterObservation(task, observation); + return this; + } + + /// Registers the callback that receives a pending task's terminal state. + /// The pending task. + /// The terminal-state observer. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void RegisterObservation(Task task, FromAsyncTaskObservation observation) => _ = task.ContinueWith( static (completedTask, state) => ((FromAsyncTaskObservation)state!).Observe(completedTask), observation, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - return this; - } /// Forwards a task that has reached a terminal state. /// The task to observe. diff --git a/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs b/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs index 2c8e4644..b58d328e 100644 --- a/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs +++ b/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs @@ -57,7 +57,7 @@ internal void Observe(Task task) } catch (Exception) when (Lifetime.IsCancellationRequested) { - // Subscription disposal owns this cancellation path and must stay silent downstream. + // Subscription cancellation does not send a terminal notification. } catch (Exception error) { diff --git a/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs b/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs index fb04d930..e63f8b20 100644 --- a/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs +++ b/src/Primitives.Shared/Advanced/FromEventConversionSignal{TEventHandler,TCallback,TResult}.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// -/// Bridges an event whose handler is built by a caller-supplied conversion, letting the handler do synchronous work -/// inside the event before the notification is published downstream. The conversion replaces reflective delegate -/// creation, so the bridge is trim- and AOT-safe. -/// +/// Bridges an event through a caller-supplied conversion that runs before publishing each notification. /// The delegate type used by the event. /// The notification callback type handed to the conversion. /// The element type published downstream. diff --git a/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs b/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs index a8b984f4..52fabb1e 100644 --- a/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs +++ b/src/Primitives.Shared/Advanced/IsEmptySignal{T}.cs @@ -48,7 +48,6 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // Acquire the subscription before the first value can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/Advanced/MaxConcurrentBlendCoordinator{T}.cs b/src/Primitives.Shared/Advanced/MaxConcurrentBlendCoordinator{T}.cs index c00d2ec6..fee8adb7 100644 --- a/src/Primitives.Shared/Advanced/MaxConcurrentBlendCoordinator{T}.cs +++ b/src/Primitives.Shared/Advanced/MaxConcurrentBlendCoordinator{T}.cs @@ -181,7 +181,7 @@ private void OnInnerCompleted(OnceDisposable inner) _ = SubscribeNext(); } - /// Marks the coordinator failed and forwards the error. Caller must hold the gate. + /// Marks the coordinator failed and forwards the error while the caller holds the gate. /// The terminal error. private void FailCore(Exception error) { @@ -189,7 +189,7 @@ private void FailCore(Exception error) _observer.OnError(error); } - /// Completes downstream once enumeration and all active sources have completed. Caller must hold the gate. + /// Completes downstream after enumeration and all active sources finish, while the caller holds the gate. private void TryCompleteCore() { if (_done || !_enumerationCompleted || _active != 0) diff --git a/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs b/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs index d2688174..2ff3ef13 100644 --- a/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs +++ b/src/Primitives.Shared/Advanced/MergeCoordinator{T}.cs @@ -84,7 +84,6 @@ public void OnSource(IObservable? source) Active++; } - // Latch completion per source so duplicate terminal notifications cannot decrement a sibling's count. var completed = 0; Subscriptions.Add(source.Subscribe( OnInnerNext, diff --git a/src/Primitives.Shared/Advanced/RaceWitness{T}.cs b/src/Primitives.Shared/Advanced/RaceWitness{T}.cs index 96028de6..2616f73e 100644 --- a/src/Primitives.Shared/Advanced/RaceWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/RaceWitness{T}.cs @@ -52,7 +52,7 @@ public RaceWitness Run(IEnumerable> sources) /// Handles completion of the outer source. private static void OnOuterCompleted() { - // Race completion is controlled by the first inner source to win. + // Only the winning source determines completion. } /// Subscribes to a candidate source. diff --git a/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs b/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs index 52b55198..deb331e4 100644 --- a/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs +++ b/src/Primitives.Shared/Advanced/RecoverSignal{T,TException}.cs @@ -10,15 +10,12 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// -/// Cold signal that mirrors the source and, on an error of type , switches to -/// the sequence the handler selects for it. Any other error, and an error thrown by the handler itself, is -/// forwarded downstream unchanged. -/// +/// Mirrors the source and switches to a handler-selected sequence on errors of the specified type. /// The value type. /// The handled exception type. /// The source observable. /// The handler that selects the fallback sequence for a caught error. +/// Other error types and exceptions thrown by the handler propagate unchanged. [System.Diagnostics.DebuggerDisplay("RecoverSignal: Source = {_source}")] public sealed class RecoverSignal(IObservable source, Func> handler) : IRequireCurrentThread where TException : Exception diff --git a/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs b/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs index 786fd5b9..546c40b7 100644 --- a/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/ResumeSignal{T}.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Reactive.Advanced; namespace ReactiveUI.Primitives.Advanced; #endif -/// Cold signal that mirrors the source and, when it produces any error, subscribes the fallback sequence in its place. The error is not forwarded downstream. +/// Replaces a failed source with the fallback sequence without forwarding the source error. /// The value type. /// The source observable. /// The fallback observable subscribed to after the source errors. diff --git a/src/Primitives.Shared/Advanced/StartSignal.cs b/src/Primitives.Shared/Advanced/StartSignal.cs index a6d89a1d..661e3e96 100644 --- a/src/Primitives.Shared/Advanced/StartSignal.cs +++ b/src/Primitives.Shared/Advanced/StartSignal.cs @@ -21,12 +21,12 @@ public StartSignal(Action action, ISequencer scheduler) Scheduler = scheduler; } + /// Gets the sequencer that runs the action. + internal ISequencer Scheduler { get; } + /// Gets the action to run. private Action Action { get; } - /// Gets the sequencer that runs the action. - private ISequencer Scheduler { get; } - /// public bool IsRequiredSubscribeOnCurrentThread() => Scheduler == Sequencer.CurrentThread; @@ -43,7 +43,7 @@ public IDisposable Subscribe(IObserver observer) /// Runs the action and forwards its terminal notification. /// The downstream observer. - private void Run(IObserver observer) + internal void Run(IObserver observer) { try { diff --git a/src/Primitives.Shared/Advanced/StartSignal{T}.cs b/src/Primitives.Shared/Advanced/StartSignal{T}.cs index e1f4141d..1f00dc8a 100644 --- a/src/Primitives.Shared/Advanced/StartSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/StartSignal{T}.cs @@ -22,12 +22,12 @@ public StartSignal(Func function, ISequencer scheduler) Scheduler = scheduler; } + /// Gets the sequencer that runs the function. + internal ISequencer Scheduler { get; } + /// Gets the function to run. private Func Function { get; } - /// Gets the sequencer that runs the function. - private ISequencer Scheduler { get; } - /// public bool IsRequiredSubscribeOnCurrentThread() => Scheduler == Sequencer.CurrentThread; @@ -44,7 +44,7 @@ public IDisposable Subscribe(IObserver observer) /// Runs the function and forwards its terminal notification. /// The downstream observer. - private void Run(IObserver observer) + internal void Run(IObserver observer) { try { diff --git a/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs b/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs index 3c054045..b2f27654 100644 --- a/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs +++ b/src/Primitives.Shared/Advanced/SwitchWitness{T}.cs @@ -80,7 +80,7 @@ private void OnSource(IObservable source) error => OnError(current, error), () => OnCompleted(current)); - // Reentrant subscription can install a newer generation; only the current generation may occupy the slot. + // Only the newest subscription remains active after reentrant subscription. var superseded = false; lock (_gate) { diff --git a/src/Primitives.Shared/Advanced/TaskFaultObservation.cs b/src/Primitives.Shared/Advanced/TaskFaultObservation.cs new file mode 100644 index 00000000..6d3f4a0e --- /dev/null +++ b/src/Primitives.Shared/Advanced/TaskFaultObservation.cs @@ -0,0 +1,25 @@ +// 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; + +#if REACTIVE_SHIM +namespace ReactiveUI.Primitives.Reactive.Advanced; +#else +namespace ReactiveUI.Primitives.Advanced; +#endif + +/// Observes faults from tasks whose result is no longer awaited. +internal static class TaskFaultObservation +{ + /// Registers fault observation without changing the task's outcome. + /// The task that may outlive its consumer. + [ExcludeFromCodeCoverage] + internal static void Register(Task task) => + _ = task.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); +} diff --git a/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs b/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs index eea0ab6e..9d20912a 100644 --- a/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs +++ b/src/Primitives.Shared/Advanced/WitnessOnSignal{T}.cs @@ -233,7 +233,7 @@ private Notification(SparkKind kind, T value, Exception? exception) public Exception Exception { get; } /// Creates an OnNext notification. - /// The value. + /// The value to carry. /// The notification. public static Notification OnNext(T value) => new(SparkKind.OnNext, value, null); diff --git a/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs b/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs index 2454e531..0a72a09d 100644 --- a/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs +++ b/src/Primitives.Shared/ConnectableSignalRxNameExtensions.cs @@ -18,7 +18,7 @@ public static class ConnectableSignalRxNameExtensions /// Connectable signal to reference count. extension(ConnectableSignal source) { - /// Shares a single subscription while observers are present. System.Reactive name for AutoShare. + /// Shares a single subscription while observers are present. /// A reference-counted sequence. public IObservable RefCount() { @@ -33,7 +33,7 @@ public IObservable RefCount() /// Source sequence to share or replay. extension(IObservable source) { - /// Creates a connectable live signal. System.Reactive name for ShareLive. + /// Creates a connectable live signal. /// A connectable live signal. public ConnectableSignal Publish() { @@ -69,7 +69,7 @@ public IObservable Publish(Func, IObservable(source, selector); } - /// Replays all source values through an unbounded replay hub. System.Reactive name for ReplayLive. + /// Replays all source values through an unbounded replay hub. /// A connectable replay signal. public ConnectableSignal Replay() { @@ -78,7 +78,7 @@ public ConnectableSignal Replay() return new(source, new ReplaySignal()); } - /// Replays source values through a bounded replay hub. System.Reactive name for ReplayLive. + /// Replays source values through a bounded replay hub. /// Maximum number of values to replay. /// A connectable replay signal. public ConnectableSignal Replay(int bufferSize) @@ -89,7 +89,7 @@ public ConnectableSignal Replay(int bufferSize) return new(source, hub); } - /// Replays source values through a replay hub constrained by count and time. System.Reactive name for ReplayLive. + /// Replays source values through a replay hub constrained by count and time. /// Maximum number of values to replay. /// Maximum replay window. /// A connectable replay signal. diff --git a/src/Primitives.Shared/Core/Spark{T}.cs b/src/Primitives.Shared/Core/Spark{T}.cs index 72fc4bf5..c1b9c26b 100644 --- a/src/Primitives.Shared/Core/Spark{T}.cs +++ b/src/Primitives.Shared/Core/Spark{T}.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Primitives.Reactive.Core; namespace ReactiveUI.Primitives.Core; #endif -/// Represents a spark to an observer. Being a value type, materializing a sequence allocates no per-notification heap object. +/// Represents a value, error, or completion notification. /// The type of the elements received by the observer. [Serializable] [DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -32,11 +32,7 @@ private Spark(SparkKind kind, T value, Exception? exception) _exception = exception; } - /// - /// Gets the value carried by an OnNext spark, or the default value for OnError and OnCompleted - /// sparks. Check (or ) to determine whether the value is - /// meaningful, and read for the error carried by an OnError spark. - /// + /// Gets the value for an OnNext notification, or the default value for an error or completion notification. public T Value { get; } /// Gets a value indicating whether the spark carries a value. diff --git a/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs b/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs index 6cb93de3..92396ac2 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.BlendUnique.cs @@ -10,20 +10,14 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// Fused Blend + Unique operator: concurrently merges a fixed set of sources and forwards a value -/// only when it differs from the last forwarded one, through one sink instead of two. -/// +/// Concurrently merges sources and suppresses values equal to the last forwarded value. public static partial class LinqExtensions { - /// - /// Concurrently merges the supplied sources and forwards only values that differ from the last forwarded - /// value, using the default equality comparer. Errors are forwarded from the first failing source; - /// completion is signalled once every source has completed. - /// + /// Concurrently merges sources and suppresses adjacent duplicate values using the default equality comparer. /// The element type. /// The sources to merge. /// An observable of the distinct merged values. + /// The first source error terminates the result; successful completion waits for every source. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservable BlendUnique(params IObservable[] sources) => BlendUnique(sources, null); @@ -105,10 +99,8 @@ private sealed class BlendUniqueSink(IObserver downstream, IEqualityCompar /// The sources to merge. public void Run(IObservable[] sources) { - // Sources and their elements are validated eagerly by the public entry point, so no null check here. if (sources.Length == 0) { - // Runs once during subscription before any source can notify, so no _done check is needed. lock (_gate) { _done = true; diff --git a/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs b/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs index f9a617d8..897ae4fd 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.ChooseSwitchSelect.cs @@ -22,10 +22,7 @@ public static partial class LinqExtensions /// The element type of the projected inner observables. /// Projects each source value to an inner observable. /// An observable that mirrors the latest projected inner observable. - /// - /// Every source value switches, a null among them included. Skipping nulls, which leaves the active - /// inner subscription in place, is instead. - /// + /// Null switches the active subscription too; use SwitchSelect to ignore nulls and keep the current subscription. public IObservable SwitchMap(Func> selector) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -35,11 +32,7 @@ public IObservable SwitchMap(Func> selector) return new SwitchMapSignal(source, selector); } - /// - /// Maps each source value to a (HasValue, Value) pair and forwards only the values whose - /// HasValue is — a single fused sink in place of Where(...).Select(...). - /// Unlike a TOut?-returning projection, the explicit flag lets a non-nullable value type be skipped. - /// + /// Projects each value to a value-and-flag pair and forwards the value only when the flag is true. /// The forwarded element type. /// Maps a source value to (HasValue, Value); the value is skipped when HasValue is . /// An observable of the chosen values. diff --git a/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs index 857f3512..53b96adc 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.CollectionSignals.cs @@ -77,7 +77,7 @@ public IDisposable Subscribe(IObserver> observer) values.Add(_range.Start + i); } - // A value-type argument is not covariant, so this cast holds only because every call site fixes T to int. + // This helper requires T to be int. observer.OnNext((IList)(object)values); observer.OnCompleted(); return EmptyDisposable.Instance; @@ -106,7 +106,7 @@ public IDisposable Subscribe(IObserver observer) values[i] = _range.Start + i; } - // A value-type argument is not covariant, so this cast holds only because every call site fixes T to int. + // This helper requires T to be int. observer.OnNext((T[])(object)values); observer.OnCompleted(); return EmptyDisposable.Instance; diff --git a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs index cadbaa0b..56c8c2cd 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.CombineLatest.cs @@ -380,7 +380,7 @@ private sealed class CombineLatestSlot( [MethodImpl(MethodImplOptions.AggressiveInlining)] internal override IDisposable Subscribe() => source.Subscribe(this); - /// Records the latest value. Called by the coordinator while it holds the serialization gate. + /// Records the latest value while the coordinator holds the serialization gate. /// The value the source produced. [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void Accept(T value) => Value = value; @@ -444,7 +444,6 @@ internal CombineLatestSlot Attach(IObservable source) /// This coordinator. internal CombineLatestCoordinator Run(Func project) { - // Installed before the first subscribe, so a source that emits inside its own Subscribe has a target. _project = project; _flags = new bool[_slots.Count * FlagsPerSource]; _missingValues = _slots.Count; diff --git a/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs b/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs index c0bce856..2fd6067f 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.Coordinators.cs @@ -388,7 +388,7 @@ internal RaceCoordinator Run(IObservable> sources) /// Handles completion of the outer sequence. private static void OnOuterCompleted() { - // Race completion is controlled by the first inner source to win. + // Only the winning source determines completion. } } diff --git a/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs index ea076748..e4c8d5dd 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.SchedulerSignals.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// Signals for the scheduler and time operators. The current-thread variants implement -/// and schedule their subscription onto the current-thread sequencer. -/// +/// Schedules current-thread subscriptions and time-based notifications. public static partial class LinqExtensions { /// Coordinates delayed notification delivery with a single serialized timer. @@ -304,6 +301,105 @@ public static DelayedNotification Completed(DateTimeOffset dueAt) => } } + /// Dedicated signal for absolute Shift overloads. + /// The value type. + /// The source observable. + /// The absolute time at which notifications may be forwarded. + /// The sequencer used to schedule delayed notifications. + internal sealed class AbsoluteShiftSignal(IObservable source, DateTimeOffset dueTime, ISequencer scheduler) : IRequireCurrentThread + { + /// The source observable. + private readonly IObservable _source = source; + + /// The absolute time at which notifications may be forwarded. + private readonly DateTimeOffset _dueTime = dueTime; + + /// The sequencer used to schedule delayed notifications. + private readonly ISequencer _scheduler = scheduler; + + /// Gets the sequencer used to schedule delayed notifications. + internal ISequencer Scheduler => _scheduler; + + /// + public bool IsRequiredSubscribeOnCurrentThread() => _scheduler == Sequencer.CurrentThread; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var dueTime = Sequencer.Normalize(_dueTime - _scheduler.Now); + return _source is RangeSignal range && typeof(T) == typeof(int) + ? new ShiftedRangeSignal(range, dueTime, _scheduler).Subscribe(observer) + : new ShiftSignal(_source, dueTime, _scheduler).Subscribe(observer); + } + } + + /// Dedicated signal for absolute DelayStart/DelaySubscription overloads. + /// The value type. + /// The source observable. + /// The absolute time at which to subscribe to the source. + /// The sequencer used to schedule the delayed subscription. + internal sealed class AbsoluteDelayStartSignal(IObservable source, DateTimeOffset dueTime, ISequencer scheduler) : IObservable + { + /// The source observable. + private readonly IObservable _source = source; + + /// The absolute time at which to subscribe to the source. + private readonly DateTimeOffset _dueTime = dueTime; + + /// The sequencer used to schedule the delayed subscription. + private readonly ISequencer _scheduler = scheduler; + + /// Gets the sequencer used to schedule the delayed subscription. + internal ISequencer Scheduler => _scheduler; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var dueTime = Sequencer.Normalize(_dueTime - _scheduler.Now); + return _source is RangeSignal range && typeof(T) == typeof(int) + ? new ShiftedRangeSignal(range, dueTime, _scheduler).Subscribe(observer) + : new DelayStartSignal(_source, dueTime, _scheduler).Subscribe(observer); + } + } + + /// Dedicated signal for absolute Expire/Timeout overloads. + /// The value type. + /// The source observable. + /// The absolute timeout time. + /// The sequencer used to schedule the timeout. + internal sealed class AbsoluteExpireSignal(IObservable source, DateTimeOffset dueTime, ISequencer scheduler) : IRequireCurrentThread + { + /// The source observable. + private readonly IObservable _source = source; + + /// The absolute timeout time. + private readonly DateTimeOffset _dueTime = dueTime; + + /// The sequencer used to schedule the timeout. + private readonly ISequencer _scheduler = scheduler; + + /// Gets the sequencer used to schedule the timeout. + internal ISequencer Scheduler => _scheduler; + + /// + public bool IsRequiredSubscribeOnCurrentThread() => + _scheduler == Sequencer.CurrentThread + || (_source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread()); + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var dueTime = Sequencer.Normalize(_dueTime - _scheduler.Now); + return new ExpireSignal(_source, dueTime, _scheduler).Subscribe(observer); + } + } + /// Dedicated signal for Calm (quiet-period debounce). /// The value type. /// The source observable. @@ -404,37 +500,6 @@ private ShiftCoordinator RunCore(IObserver observer) } } - /// Dedicated signal for absolute Shift overloads. - /// The value type. - /// The source observable. - /// The absolute time at which notifications may be forwarded. - /// The sequencer used to schedule delayed notifications. - private sealed class AbsoluteShiftSignal(IObservable source, DateTimeOffset dueTime, ISequencer scheduler) : IRequireCurrentThread - { - /// The source observable. - private readonly IObservable _source = source; - - /// The absolute time at which notifications may be forwarded. - private readonly DateTimeOffset _dueTime = dueTime; - - /// The sequencer used to schedule delayed notifications. - private readonly ISequencer _scheduler = scheduler; - - /// - public bool IsRequiredSubscribeOnCurrentThread() => _scheduler == Sequencer.CurrentThread; - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - var dueTime = Sequencer.Normalize(_dueTime - _scheduler.Now); - return _source is RangeSignal range && typeof(T) == typeof(int) - ? new ShiftedRangeSignal(range, dueTime, _scheduler).Subscribe(observer) - : new ShiftSignal(_source, dueTime, _scheduler).Subscribe(observer); - } - } - /// Dedicated signal for SubscribeOn (defer subscription to a sequencer). /// The value type. /// The source observable. @@ -498,65 +563,6 @@ public IDisposable Subscribe(IObserver observer) } } - /// Dedicated signal for absolute DelayStart/DelaySubscription overloads. - /// The value type. - /// The source observable. - /// The absolute time at which to subscribe to the source. - /// The sequencer used to schedule the delayed subscription. - private sealed class AbsoluteDelayStartSignal(IObservable source, DateTimeOffset dueTime, ISequencer scheduler) : IObservable - { - /// The source observable. - private readonly IObservable _source = source; - - /// The absolute time at which to subscribe to the source. - private readonly DateTimeOffset _dueTime = dueTime; - - /// The sequencer used to schedule the delayed subscription. - private readonly ISequencer _scheduler = scheduler; - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - var dueTime = Sequencer.Normalize(_dueTime - _scheduler.Now); - return _source is RangeSignal range && typeof(T) == typeof(int) - ? new ShiftedRangeSignal(range, dueTime, _scheduler).Subscribe(observer) - : new DelayStartSignal(_source, dueTime, _scheduler).Subscribe(observer); - } - } - - /// Dedicated signal for absolute Expire/Timeout overloads. - /// The value type. - /// The source observable. - /// The absolute timeout time. - /// The sequencer used to schedule the timeout. - private sealed class AbsoluteExpireSignal(IObservable source, DateTimeOffset dueTime, ISequencer scheduler) : IRequireCurrentThread - { - /// The source observable. - private readonly IObservable _source = source; - - /// The absolute timeout time. - private readonly DateTimeOffset _dueTime = dueTime; - - /// The sequencer used to schedule the timeout. - private readonly ISequencer _scheduler = scheduler; - - /// - public bool IsRequiredSubscribeOnCurrentThread() => - _scheduler == Sequencer.CurrentThread - || (_source is IRequireCurrentThread currentThread && currentThread.IsRequiredSubscribeOnCurrentThread()); - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - var dueTime = Sequencer.Normalize(_dueTime - _scheduler.Now); - return new ExpireSignal(_source, dueTime, _scheduler).Subscribe(observer); - } - } - /// Dedicated signal for Reattempt (retry on error). /// The value type. /// The source observable. diff --git a/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs b/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs index bcd24388..869bf3e3 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.StatefulSignals.cs @@ -36,7 +36,6 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // Acquire the subscription before the trampoline delivers values that can terminate it. if (!CurrentThreadRequirement.IsRequired(_source) || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); @@ -82,7 +81,6 @@ public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - // Acquire both subscriptions before either trampoline starts delivering values. if ((!CurrentThreadRequirement.IsRequired(_source) && !CurrentThreadRequirement.IsRequired(_other)) || !CurrentThreadSequencer.IsScheduleRequired) { @@ -454,7 +452,6 @@ public IDisposable Subscribe(IObserver observer) { ArgumentExceptionHelper.ThrowIfNull(observer); - // Acquire the subscription before a predicate can stop the source. if (!CurrentThreadRequirement.IsRequired(_source) || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/SignalOperatorMixins.Switch.cs b/src/Primitives.Shared/SignalOperatorMixins.Switch.cs index d098d34f..282d2289 100644 --- a/src/Primitives.Shared/SignalOperatorMixins.Switch.cs +++ b/src/Primitives.Shared/SignalOperatorMixins.Switch.cs @@ -63,33 +63,29 @@ internal SwitchCoordinator Run(IObservable> sources) return this; } - /// Switches to a new inner source. - /// The new inner source. - private void OnSource(IObservable source) + /// Activates the next inner generation unless a terminal notification has been sent. + /// The activated generation, or zero when the coordinator is done. + /// True when an inner generation was activated; otherwise, false. + internal bool TryBeginSource(out int version) { - int current; lock (_gate) { if (_done) { - return; + version = 0; + return false; } - current = _version + 1; + version = _version + 1; - // Publish the new version so readers in gated operations observe it. - Volatile.Write(ref _version, current); + Volatile.Write(ref _version, version); _innerActive = true; + return true; } - - _innerSlot.Create(source.Subscribe( - value => OnNext(current, value), - error => OnError(current, error), - () => OnCompleted(current))); } /// Marks the outer source as complete. - private void OnOuterCompleted() + internal void OnOuterCompleted() { lock (_gate) { @@ -105,7 +101,7 @@ private void OnOuterCompleted() /// Forwards an outer source error once. /// The error to forward. - private void OnOuterError(Exception error) + internal void OnOuterError(Exception error) { lock (_gate) { @@ -122,7 +118,7 @@ private void OnOuterError(Exception error) /// Forwards an inner value when it belongs to the current source. /// The inner version. /// The value to forward. - private void OnNext(int version, T value) + internal void OnNext(int version, T value) { lock (_gate) { @@ -138,7 +134,7 @@ private void OnNext(int version, T value) /// Forwards an inner error when it belongs to the current source. /// The inner version. /// The error to forward. - private void OnError(int version, Exception error) + internal void OnError(int version, Exception error) { lock (_gate) { @@ -154,7 +150,7 @@ private void OnError(int version, Exception error) /// Completes an inner source when it belongs to the current source. /// The inner version. - private void OnCompleted(int version) + internal void OnCompleted(int version) { lock (_gate) { @@ -168,6 +164,21 @@ private void OnCompleted(int version) } } + /// Switches to a new inner source. + /// The new inner source. + private void OnSource(IObservable source) + { + if (!TryBeginSource(out var current)) + { + return; + } + + _innerSlot.Create(source.Subscribe( + value => OnNext(current, value), + error => OnError(current, error), + () => OnCompleted(current))); + } + /// Completes the observer when both outer and inner sources are complete. private void TryComplete() { diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs index 0b0e84f3..ef82822e 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.AggregateHelpers.cs @@ -338,7 +338,6 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // Acquire the subscription before the first value can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); @@ -393,7 +392,6 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // Acquire the subscription before a match can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs b/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs index 24910d2c..ae788b80 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.AwaitableTerminals.cs @@ -66,8 +66,7 @@ public Task FirstOrDefaultAsync(T defaultValue) /// The token used to cancel the task and dispose the subscription. /// A task that completes with the first source value, or when the source is empty. /// The receiver sequence is . - /// Deprioritized, so a call like FirstOrDefaultAsync(default!) binds to the - /// FirstOrDefaultAsync(T) overload rather than this one. + /// A call to FirstOrDefaultAsync(default!) selects the default-value overload. [OverloadResolutionPriority(-1)] public Task FirstOrDefaultAsync(CancellationToken cancellationToken) { @@ -141,8 +140,7 @@ public Task LastOrDefaultAsync(T defaultValue) /// The token used to cancel the task and dispose the subscription. /// A task that completes with the final source value, or when the source is empty. /// The receiver sequence is . - /// Deprioritized, so a call like LastOrDefaultAsync(default!) binds to the - /// LastOrDefaultAsync(T) overload rather than this one. + /// A call to LastOrDefaultAsync(default!) selects the default-value overload. [MethodImpl(MethodImplOptions.AggressiveInlining)] [OverloadResolutionPriority(-1)] public Task LastOrDefaultAsync(CancellationToken cancellationToken) => diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs index e386144f..3051d17c 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.BooleanTerminalHelpers.cs @@ -38,7 +38,6 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // Acquire the subscription before a rejected value can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); @@ -127,7 +126,6 @@ public IDisposable Subscribe(IObserver observer) return EmptyDisposable.Instance; } - // Acquire the subscription before a match can terminate the source. if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) { return SubscribeCore(observer); diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs index 26f721cf..5e1aa6b1 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs @@ -228,7 +228,6 @@ private void OnOuterCompleted() /// The inner value. private void OnInnerNext(TResult value) { - // One inner source runs at a time; the volatile disposal check needs no delivery gate. if (Volatile.Read(ref _disposed)) { return; diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs b/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs index 026c59f7..6e383544 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.Helpers.cs @@ -543,7 +543,6 @@ private void OnCompleted() _done = true; - // Completion flushes the pending value; the timer clears the same flag under this gate. if (_hasLatest) { _hasLatest = false; @@ -576,7 +575,6 @@ private void Tick() return; } - // Serialize the timer emission against a concurrent terminal notification. lock (_gate) { if (_done) diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs b/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs index 4a9d399f..e3fd9f66 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.Probe.cs @@ -109,7 +109,7 @@ public void Dispose() return; } - // Latch termination to reject notifications from sources that ignore disposal. + // Notifications received after termination are ignored. lock (_gate) { _done = true; @@ -206,7 +206,7 @@ private void ScheduleNext() /// An empty disposable. private EmptyDisposable Tick() { - // Hold the gate across the emission so the sample cannot interleave with a terminal. + // Samples cannot interleave with terminal notifications. lock (_gate) { if (_done || !_hasLatest) diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs index 66cd1e6c..9c57216b 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.CombineLatest.Collections.cs @@ -24,7 +24,7 @@ public static partial class LinqExtensions /// may keep it. An empty source collection produces an empty sequence. /// /// or one of its elements is . - /// The collection is enumerated once, when the operator is called, not on each subscription. + /// The collection is enumerated once when the operator is called. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable> CombineLatest() => CombineLatestOf(CombineLatestSources(sources)); @@ -33,7 +33,7 @@ public static partial class LinqExtensions /// Projects the latest value of every source into a result. /// An observable sequence of projected results. An empty source collection produces an empty sequence. /// , one of its elements, or is . - /// The collection is enumerated once, when the operator is called, not on each subscription. + /// The collection is enumerated once when the operator is called. public IObservable CombineLatest(Func, TResult> resultSelector) { ArgumentExceptionHelper.ThrowIfNull(resultSelector); @@ -54,8 +54,7 @@ public IObservable CombineLatest(Func, TResult> resul /// /// or one of its elements is . /// - /// Ranked below the tuple overloads, so two to sixteen same-typed sources listed inline bind to the tuple - /// overload instead; this one takes over past that arity and whenever the sources arrive as an array. + /// Inline calls with two through sixteen same-typed sources select tuple overloads; larger calls and arrays select this overload. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] [OverloadResolutionPriority(-2)] diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs index 08b03163..17565226 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.RxNames.cs @@ -10,10 +10,7 @@ namespace ReactiveUI.Primitives.Reactive; namespace ReactiveUI.Primitives; #endif -/// -/// System.Reactive and LINQ names for the Primitives operator vocabulary. Each one builds the same sink as its -/// Primitives-named counterpart, so either name can be used interchangeably. -/// +/// Provides System.Reactive and LINQ aliases for the Primitives operators. public static partial class LinqExtensions { /// System.Reactive-named combining operators for enumerable observable sources. @@ -21,7 +18,7 @@ public static partial class LinqExtensions /// The observable sources. extension(IEnumerable> sources) { - /// Concurrently merges the supplied observable sources. System.Reactive name for Blend. + /// Concurrently merges the supplied observable sources. /// An observable that forwards values from every source. /// is . public IObservable Merge() @@ -51,7 +48,7 @@ public IObservable Merge(int maxConcurrent) /// The outer sequence of inner sequences. extension(IObservable> sources) { - /// Subscribes to all inner sequences and forwards their values as they arrive. System.Reactive name for Blend. + /// Subscribes to all inner sequences and forwards their values as they arrive. /// A sequence containing values from all inner sequences. /// is . public IObservable Merge() @@ -61,7 +58,7 @@ public IObservable Merge() return new BlendSignal(sources); } - /// Subscribes to inner sequences one at a time in source order. System.Reactive name for Chain. + /// Subscribes to inner sequences one at a time in source order. /// A sequence that emits each inner sequence after the previous one completes. /// is . public IObservable Concat() @@ -71,7 +68,7 @@ public IObservable Concat() return new ChainSignal(sources); } - /// Mirrors the first inner sequence to produce any notification. System.Reactive name for Race. + /// Mirrors the first inner sequence to produce any notification. /// A sequence that mirrors the winning inner sequence. /// is . public IObservable Amb() @@ -81,7 +78,7 @@ public IObservable Amb() return new RaceSignal(sources); } - /// Switches to the most recent inner sequence. System.Reactive name for SwitchTo. + /// Switches to the most recent inner sequence. /// A sequence that mirrors only the latest inner sequence. /// is . public IObservable Switch() @@ -99,7 +96,7 @@ public IObservable Switch() /// The spark sequence. extension(IObservable> source) { - /// Converts values back into observer notifications. System.Reactive name for Unspark. + /// Converts values back into observer notifications. /// A sequence represented by the supplied spark values. /// is . public IObservable Dematerialize() @@ -127,8 +124,8 @@ public IObservable Dematerialize() /// A disposable that cancels the subscription. /// or is . /// - /// System.Reactive declares an observer-taking SubscribeSafe in the System namespace, so the - /// two are ambiguous whenever both packages are in scope; the other overloads have no such counterpart. + /// The observer overload is ambiguous when System.Reactive also supplies SubscribeSafe in the System namespace; other overloads are + /// unaffected. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public IDisposable SubscribeSafePrimitives(IObserver observer) => SubscribeSafeCore(source, observer); @@ -169,7 +166,7 @@ public IDisposable SubscribeSafe(Action onError) => public IDisposable SubscribeSafe(Action onError, Action onCompleted) => SubscribeSafeCore(source, Witness.Create(static _ => { }, onError, onCompleted)); - /// Invokes an action for each value while preserving the sequence. System.Reactive name for Tap. + /// Invokes an action for each value while preserving the sequence. /// The action to invoke for each value. /// The source values after the action has run. /// or is . @@ -182,7 +179,7 @@ public IObservable Do(Action onNext) return new TapSignal(source, onNext, static _ => { }, static () => { }); } - /// Invokes actions for each value and error while preserving the sequence. System.Reactive name for Tap. + /// Invokes actions for each value and error while preserving the sequence. /// The action to invoke for each value. /// The action to invoke for an error. /// The source values after the actions have run. @@ -198,7 +195,7 @@ public IObservable Do(Action onNext, Action onError) return new TapSignal(source, onNext, onError, static () => { }); } - /// Invokes actions for each value and completion while preserving the sequence. System.Reactive name for Tap. + /// Invokes actions for each value and completion while preserving the sequence. /// The action to invoke for each value. /// The action to invoke when the sequence completes. /// The source values after the actions have run. @@ -214,7 +211,7 @@ public IObservable Do(Action onNext, Action onCompleted) return new TapSignal(source, onNext, static _ => { }, onCompleted); } - /// Invokes actions for each value, error, and completion while preserving the sequence. System.Reactive name for Tap. + /// Invokes actions for each value, error, and completion while preserving the sequence. /// The action to invoke for each value. /// The action to invoke for an error. /// The action to invoke when the sequence completes. @@ -236,11 +233,7 @@ public IObservable Do( return new TapSignal(source, onNext, onError, onCompleted); } - /// - /// Serializes notifications behind a gate so downstream operators observe the single-threaded - /// OnNext* then OnError|OnCompleted grammar even when the source delivers - /// concurrently. System.Reactive name for the same operation. - /// + /// Serializes concurrent notifications so values precede a single terminal notification. /// A sequence that forwards the source notifications one at a time. /// is . public IObservable Synchronize() @@ -250,11 +243,7 @@ public IObservable Synchronize() return new SynchronizeSignal(source); } - /// - /// Serializes notifications behind the supplied , so this sequence and every other - /// sequence synchronized on the same gate observe the single-threaded grammar relative to one another. - /// System.Reactive name for the same operation. - /// + /// Serializes notifications with every sequence using the supplied gate. /// The gate shared with other synchronized sequences. /// A sequence that forwards the source notifications one at a time under the shared gate. /// or is . @@ -297,7 +286,7 @@ public IObservable Synchronize(object gate) } #endif - /// Invokes a stateful action for each value while preserving the sequence. State-carrying name for TapWith. + /// Invokes a stateful action for each value while preserving the sequence. /// The state type. /// The state passed to . /// The action to invoke for each value. @@ -312,7 +301,7 @@ public IObservable DoWith(TState state, Action onNext) return new TapWithSignal(source, state, onNext); } - /// Emits the accumulated state after each source value. System.Reactive name for Fold. + /// Emits the accumulated state after each source value. /// The accumulated value type. /// The initial accumulated value. /// The function that combines the current state with the next source value. @@ -329,7 +318,7 @@ public IObservable Scan( return new FoldSignal(source, seed, accumulator); } - /// Emits the final accumulated state when the source completes. System.Reactive name for Reduce. + /// Emits the final accumulated state when the source completes. /// The accumulated value type. /// The initial accumulated value. /// The function that combines the current state with the next source value. @@ -346,7 +335,7 @@ public IObservable Aggregate( return new ReduceSignal(source, seed, accumulator); } - /// Suppresses adjacent duplicate values. System.Reactive name for Unique. + /// Suppresses adjacent duplicate values. /// A sequence with adjacent duplicates removed. /// is . public IObservable DistinctUntilChanged() @@ -356,7 +345,7 @@ public IObservable DistinctUntilChanged() return new UniqueSignal(source, EqualityComparer.Default); } - /// Suppresses adjacent duplicate values using the supplied comparer. System.Reactive name for Unique. + /// Suppresses adjacent duplicate values using the supplied comparer. /// The comparer used to compare adjacent values. /// A sequence with adjacent duplicates removed. /// is . @@ -368,7 +357,7 @@ public IObservable DistinctUntilChanged(IEqualityComparer? comparer) return new UniqueSignal(source, comparer); } - /// Suppresses adjacent values with duplicate keys. System.Reactive name for UniqueBy. + /// Suppresses adjacent values with duplicate keys. /// The key type. /// The function that selects the comparison key. /// A sequence with adjacent duplicate keys removed. @@ -382,7 +371,7 @@ public IObservable DistinctUntilChangedBy(Func keySelector) return new UniqueBySignal(source, keySelector, EqualityComparer.Default); } - /// Suppresses adjacent values with duplicate keys using the supplied comparer. System.Reactive name for UniqueBy. + /// Suppresses adjacent values with duplicate keys using the supplied comparer. /// The key type. /// The function that selects the comparison key. /// The comparer used to compare adjacent keys. @@ -398,7 +387,7 @@ public IObservable DistinctUntilChangedBy(Func keySelector, IE return new UniqueBySignal(source, keySelector, comparer); } - /// Drops every value, forwarding only the terminal notification. System.Reactive name for IgnoreValues. + /// Drops every value, forwarding only the terminal notification. /// A sequence that forwards only completion or error. /// is . public IObservable IgnoreElements() @@ -408,7 +397,7 @@ public IObservable IgnoreElements() return new IgnoreValuesSignal(source); } - /// Prepends values before the source sequence. System.Reactive name for Prepend. + /// Prepends values before the source sequence. /// The values to emit before the source. /// A sequence that emits before the source values. /// or is . @@ -428,7 +417,7 @@ public IObservable StartWith(params T[] values) : new StartWithEnumerableSignal(source, values); } - /// Prepends values before the source sequence. System.Reactive name for Prepend. + /// Prepends values before the source sequence. /// The values to emit before the source. /// A sequence that emits before the source values. /// or is . @@ -441,7 +430,7 @@ public IObservable StartWith(IEnumerable values) return new StartWithEnumerableSignal(source, values); } - /// Collects values into time-windowed batches. System.Reactive name for Collect. + /// Collects values into time-windowed batches. /// The duration of each buffer window. /// A sequence that emits non-empty batches of source values. /// is . @@ -466,7 +455,7 @@ public IObservable> Buffer(TimeSpan timeSpan, ISequencer scheduler) return new BufferSignal(source, timeSpan, scheduler); } - /// Invokes an action when the subscription terminates or is disposed. System.Reactive name for OnCleanup. + /// Invokes an action when the subscription terminates or is disposed. /// The action to invoke exactly once. /// A sequence that mirrors the source and invokes on cleanup. /// or is . @@ -479,7 +468,7 @@ public IObservable Finally(Action finallyAction) return new FinallySignal(source, finallyAction); } - /// Emits a value only after no newer value arrives within the quiet period. System.Reactive name for Calm. + /// Emits a value only after no newer value arrives within the quiet period. /// The quiet period. /// A sequence that emits the latest value after each quiet period. /// is . @@ -490,7 +479,7 @@ public IObservable Throttle(TimeSpan dueTime) return new CalmSignal(source, dueTime, ThreadPoolSequencer.Instance); } - /// Emits a value only after no newer value arrives within the scheduler quiet period. System.Reactive name for Calm. + /// Emits a value only after no newer value arrives within the scheduler quiet period. /// The quiet period. /// The scheduler used to schedule quiet-period timers. /// A sequence that emits the latest value after each quiet period. @@ -518,7 +507,7 @@ public IObservable Catch(Func> handler return new RecoverSignal(source, handler); } - /// Projects each value to an inner sequence and merges the results. LINQ name for concurrent flattening. + /// Projects each value to an inner sequence and merges the results. /// The inner value type. /// The function that projects each source value to an inner sequence. /// A sequence containing the merged values of every inner sequence. @@ -580,7 +569,7 @@ public IObservable SelectMany( return new SelectManyResultSignal(source, collectionSelector, resultSelector); } - /// Merges this sequence with another observable sequence. System.Reactive name for Blend. + /// Merges this sequence with another observable sequence. /// The second sequence to merge. /// A sequence containing values from both sources as they arrive. /// or is . @@ -593,7 +582,7 @@ public IObservable Merge(IObservable second) return new MergeSignal(source, second); } - /// Concatenates two sequences. System.Reactive name for Chain. + /// Concatenates two sequences. /// The second sequence. /// A sequence that emits after completes. /// or is . @@ -626,7 +615,7 @@ public IObservable OnErrorResumeNext(IObservable second) extension(IObservable source) where T : class { - /// Filters out null values, emitting only non-null values. Familiar name for KeepNotNull. + /// Filters out null values, emitting only non-null values. /// An observable sequence that emits only the non-null values from the source sequence. /// is . public IObservable WhereNotNull() @@ -642,7 +631,7 @@ public IObservable WhereNotNull() /// The left sequence. extension(IObservable left) { - /// Combines paired values from two sequences by index. System.Reactive name for Pair. + /// Combines paired values from two sequences by index. /// The right value type. /// The result value type. /// The right sequence. @@ -665,7 +654,7 @@ public IObservable Zip( : new ZipSignal(left, right, selector); } - /// Combines the latest values once both sequences have produced a value. System.Reactive name for SyncLatest. + /// Combines the latest values once both sequences have produced a value. /// The right value type. /// The result value type. /// The right sequence. @@ -691,7 +680,7 @@ public IObservable CombineLatest( : new CombineLatestSignal(left, right, selector); } - /// Combines each left value with the latest right value. System.Reactive name for Latch. + /// Combines each left value with the latest right value. /// The right value type. /// The result value type. /// The sequence that supplies the latest value. @@ -714,7 +703,7 @@ public IObservable WithLatestFrom( : new LatchSignal(left, right, selector); } - /// Delays source notifications by the specified duration. System.Reactive name for Shift. + /// Delays source notifications by the specified duration. /// The delay applied to each notification. /// A sequence that forwards source notifications after the delay. public IObservable Delay(TimeSpan dueTime) @@ -726,7 +715,7 @@ public IObservable Delay(TimeSpan dueTime) : new ShiftSignal(left, dueTime, ThreadPoolSequencer.Instance); } - /// Delays source notifications by the specified duration on a sequencer. System.Reactive name for Shift. + /// Delays source notifications by the specified duration on a sequencer. /// The delay applied to each notification. /// The sequencer used to schedule delayed notifications. /// A sequence that forwards source notifications after the delay. @@ -762,7 +751,7 @@ public IObservable Delay(DateTimeOffset dueTime, ISequencer? scheduler) return new AbsoluteShiftSignal(left, dueTime, scheduler); } - /// Fails the sequence if it does not terminate before the timeout. System.Reactive name for Expire. + /// Fails the sequence if it does not terminate before the timeout. /// The timeout duration. /// A sequence that errors with when the timeout elapses first. public IObservable Timeout(TimeSpan dueTime) @@ -772,7 +761,7 @@ public IObservable Timeout(TimeSpan dueTime) return new ExpireSignal(left, dueTime, ThreadPoolSequencer.Instance); } - /// Fails the sequence if it does not terminate before the sequencer timeout. System.Reactive name for Expire. + /// Fails the sequence if it does not terminate before the sequencer timeout. /// The timeout duration. /// The sequencer used to schedule the timeout. /// A sequence that errors with when the timeout elapses first. @@ -806,7 +795,7 @@ public IObservable Timeout(DateTimeOffset dueTime, ISequencer? scheduler) return new AbsoluteExpireSignal(left, dueTime, scheduler); } - /// Emits the most recent value at the end of each sampling period. System.Reactive name for Probe. + /// Emits the most recent value at the end of each sampling period. /// The sampling period. /// A sequence containing the latest source value sampled at each period boundary. public IObservable Sample(TimeSpan interval) @@ -818,7 +807,7 @@ public IObservable Sample(TimeSpan interval) return new ProbeSignal(left, interval, ThreadPoolSequencer.Instance); } - /// Emits the most recent value at the end of each sampling period on a sequencer. System.Reactive name for Probe. + /// Emits the most recent value at the end of each sampling period on a sequencer. /// The sampling period. /// The sequencer used to schedule sampling. /// A sequence containing the latest source value sampled at each period boundary. @@ -834,7 +823,7 @@ public IObservable Sample(TimeSpan interval, ISequencer? scheduler) return new ProbeSignal(left, interval, scheduler); } - /// Resubscribes to the source after an error up to times. System.Reactive name for Reattempt. + /// Resubscribes to the source after an error up to times. /// The maximum number of retry attempts after the initial subscription. /// A sequence that retries the source before forwarding the final error. /// is . @@ -848,7 +837,7 @@ public IObservable Retry(int retryCount) return new ReattemptSignal(left, retryCount); } - /// Converts source values and terminal notifications into values. System.Reactive name for Spark. + /// Converts source values and terminal notifications into values. /// A sequence of spark values representing source notifications. /// is . public IObservable> Materialize() @@ -864,7 +853,7 @@ public IObservable> Materialize() /// An observable sequence of elements to project. extension(IObservable source) { - /// Projects each element of an observable sequence into a new form. LINQ name for Map. + /// Projects each element of an observable sequence into a new form. /// The type of the elements in the result sequence. /// A transform function to apply to each element. /// An observable sequence whose elements are the result of invoking the transform function on each source element. @@ -878,7 +867,7 @@ public IObservable Select(Func selector) return new MapSignal(source, selector); } - /// Projects each element and its zero-based index into a new form. LINQ name for MapIndexed. + /// Projects each element and its zero-based index into a new form. /// The type of the elements in the result sequence. /// A transform function to apply to each element and its index. /// An observable sequence whose elements are the result of invoking the transform on each source element and index. @@ -892,7 +881,7 @@ public IObservable Select(Func selector return new MapIndexedSignal(source, selector); } - /// Projects each element into a new form using external state passed to the selector. State-carrying name for MapWith. + /// Projects each element into a new form using external state passed to the selector. /// The type of the state used in the selector function. /// The type of the elements in the result sequence. /// The state to pass to the selector function. @@ -908,7 +897,7 @@ public IObservable SelectWith(TState state, Func(source, state, selector); } - /// Filters an observable sequence to elements that satisfy a predicate. LINQ name for Keep. + /// Filters an observable sequence to elements that satisfy a predicate. /// A function to test each element for a condition. /// An observable sequence containing the elements that satisfy . /// or is . @@ -921,7 +910,7 @@ public IObservable Where(Func predicate) return new KeepSignal(source, predicate); } - /// Filters elements using a predicate that uses external state. State-carrying name for KeepWith. + /// Filters elements using a predicate that uses external state. /// The type of the state parameter passed to the predicate. /// The state value to pass to the predicate for each element. /// A function to test each element along with the state. @@ -942,7 +931,7 @@ public IObservable WhereWith(TState state, FuncThe outer sequence of task sources. extension(IObservable> sources) { - /// Subscribes to task results one at a time in source order. System.Reactive name for Chain. + /// Subscribes to task results one at a time in source order. /// A sequence that emits each task result after the previous task signal completes. /// is . public IObservable Concat() @@ -957,7 +946,7 @@ public IObservable Concat() /// The source sequence. extension(IObservable source) { - /// Filters values to those assignable to . System.Reactive name for KeepType. + /// Filters values to those assignable to . /// The result value type. /// A sequence containing only values assignable to . /// is . @@ -972,7 +961,7 @@ public IObservable OfType() return new KeepTypeSignal(source); } - /// Casts each source value to . System.Reactive name for CastTo. + /// Casts each source value to . /// The result value type. /// A sequence containing each value cast to . /// is . diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.cs b/src/Primitives.Shared/SignalOperatorParityMixins.cs index a47bd918..5f0a645a 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.cs @@ -60,7 +60,7 @@ public IObservable ToObservable(CancellationToken cancellationToken) /// The source sequence. extension(IObservable source) { - /// Prepends a value before the source sequence. Alias of Prepend using Primitives vocabulary. + /// Prepends a value before the source sequence. /// The value to emit before the source. /// A sequence that emits before the source values. /// The receiver sequence is . @@ -128,7 +128,7 @@ public IObservable Append(T value) : new AppendSignal(source, value); } - /// Returns the source as an observable. This is an identity adapter for BCL observable sources. + /// Returns the source observable unchanged. /// The supplied source sequence. /// The receiver sequence is . public IObservable AsObservable() => source ?? throw new ArgumentNullException(nameof(source)); @@ -836,12 +836,8 @@ private static async Task AwaitWithCancellationAsync(Task task, Cancell } catch (OperationCanceledException) { - // Observe faults from work that outlives cancellation of the wait. - _ = task.ContinueWith( - static abandoned => _ = abandoned.Exception, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + // Faults are observed even after the wait is cancelled. + TaskFaultObservation.Register(task); throw; } } diff --git a/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs b/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs index b971523a..151b145d 100644 --- a/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs +++ b/src/Primitives.Shared/Signals/PrioritySemaphoreSignal{T}.cs @@ -103,17 +103,8 @@ public void OnNext(T value) /// Releases one semaphore slot and drains queued values when capacity is available. public void Release() { - int previousCount; - do - { - previousCount = Volatile.Read(ref _count); - if (previousCount <= 0) - { - return; - } - } while (Interlocked.CompareExchange(ref _count, previousCount - 1, previousCount) != previousCount); - - YieldUntilEmptyOrBlocked(); + var previousCount = Volatile.Read(ref _count); + ReleaseObserved(previousCount); } /// @@ -174,6 +165,23 @@ public void Dispose() _inner.Dispose(); } + /// Releases one occupied slot, retrying when the observed count has changed. + /// The count observed before attempting release. + internal void ReleaseObserved(int previousCount) + { + while (previousCount > 0) + { + var currentCount = Interlocked.CompareExchange(ref _count, previousCount - 1, previousCount); + if (currentCount == previousCount) + { + YieldUntilEmptyOrBlocked(); + return; + } + + previousCount = currentCount; + } + } + /// Queues a value while the signal accepts input. /// The value to enqueue. /// when the value was queued; otherwise, . @@ -208,7 +216,6 @@ private void YieldUntilEmptyOrBlocked() Deliver(item); } - // TryTakeNextDrainItem cleared ownership when it returned false. owned = false; } finally @@ -248,7 +255,6 @@ private bool TryTakeNextDrainItem(out DrainItem item) return true; } - // Release ownership under the queue gate so subsequent producers can start a drain. _isDraining = false; return false; } diff --git a/src/Primitives.Shared/Signals/ReplaySignal{T}.cs b/src/Primitives.Shared/Signals/ReplaySignal{T}.cs index c051205e..b743f5b5 100644 --- a/src/Primitives.Shared/Signals/ReplaySignal{T}.cs +++ b/src/Primitives.Shared/Signals/ReplaySignal{T}.cs @@ -220,7 +220,6 @@ public void OnError(Exception error) /// Concurrent subscription receives each value once, through replay or live delivery, in emission order. public void OnNext(T value) { - // Read the scheduler clock outside the lock; the window inputs are immutable. var interval = _usesWindow ? _scheduler.Now - _startTime : TimeSpan.Zero; lock (_observerLock) { diff --git a/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs b/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs index e4a506ca..8e8efc8d 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{Create}.cs @@ -46,7 +46,7 @@ public IEnumerable ToEnumerable() }, completed.Set); - completed.Wait(); + WaitForCompletion(completed); if (error is not null) { @@ -56,4 +56,10 @@ public IEnumerable ToEnumerable() return values; } } + + /// Blocks until the source signals completion. + /// The source's completion signal. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WaitForCompletion(ManualResetEventSlim completed) => completed.Wait(); } diff --git a/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs b/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs index 52c95051..dbe02530 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{FromTask}.cs @@ -14,7 +14,7 @@ namespace ReactiveUI.Primitives.Signals; public static partial class SignalExtensions { /// Cancellation-handling operators for an observable source sequence. - /// The type. + /// The source element type. /// The asynchronous task. extension(IObservable asyncTask) { @@ -34,7 +34,7 @@ public static partial class SignalExtensions try { token.ThrowIfCancellationRequested(); - return await Task.Run(async () => await asyncTask, token).ConfigureAwait(false); + return await AwaitOnTaskPoolAsync(asyncTask, token).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -110,19 +110,12 @@ public async Task HandleCancellation(Action? action) try { - // Create a task that completes when either the async operation completes, - // or cancellation is requested. var readyTask = await Task.WhenAny(asyncTask, cancellationTask).ConfigureAwait(false); - // In case of cancellation, register a continuation to observe any unhandled - // exceptions from the asynchronous operation once it completes. + // Faults from work that outlives cancellation are still observed. if (readyTask == cancellationTask) { - _ = asyncTask.ContinueWith( - static task => _ = task.Exception, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + TaskFaultObservation.Register(asyncTask); } return (await readyTask.ConfigureAwait(false), tcs.Task.IsCanceled || readyTask.IsCanceled); @@ -137,4 +130,14 @@ public async Task HandleCancellation(Action? action) } } } + + /// Starts an observable wait on the task pool. + /// The source value type. + /// The source to await. + /// The token cancelling dispatch. + /// The source's terminal result. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Task AwaitOnTaskPoolAsync(IObservable source, CancellationToken token) => + Task.Run(async () => await source, token); } diff --git a/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs b/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs index 8172b432..285a36d5 100644 --- a/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs +++ b/src/Primitives.Shared/Signals/SignalExtensions{GetAwaiter}.cs @@ -18,20 +18,14 @@ public static partial class SignalExtensions /// Source sequence to await. extension(IObservable source) { - /// - /// Gets an awaiter that returns the last value of the observable sequence or throws an exception if the sequence is empty. - /// This operation subscribes to the observable sequence, making it hot. - /// + /// Subscribes immediately and returns an awaiter for the last value, throwing if the sequence is empty. /// A final signal awaiter. /// is . [MethodImpl(MethodImplOptions.AggressiveInlining)] public IAwaitSignal GetAwaiter() => Signal.RunAsync(source); - /// - /// Gets an awaiter that returns the last value of the observable sequence or throws an exception if the sequence is empty. - /// This operation subscribes to the observable sequence, making it hot. - /// + /// Subscribes immediately and returns an awaiter for the last value, throwing if the sequence is empty. /// Token that cancels the await and disposes the subscription. /// A final signal awaiter. /// is . diff --git a/src/Primitives.Shared/Signals/Signal{Create}.cs b/src/Primitives.Shared/Signals/Signal{Create}.cs index 182218f6..163f42ec 100644 --- a/src/Primitives.Shared/Signals/Signal{Create}.cs +++ b/src/Primitives.Shared/Signals/Signal{Create}.cs @@ -15,10 +15,7 @@ namespace ReactiveUI.Primitives.Signals; /// Factory methods that build signals from subscribe functions and deferred sources. public static partial class Signal { - /// - /// Creates a signal that runs the subscribe function for each observer. A downstream OnNext that throws - /// leaves the subscription intact, which suits hot signals built over operators and events. - /// + /// Runs the subscription factory for each observer and preserves the subscription when a downstream OnNext throws. /// The element type. /// Invoked for each observer; returns the disposable that releases the subscription. /// A signal backed by . @@ -31,7 +28,7 @@ public static IObservable Create(Func, IDisposable> subscribe } /// Creates an observable from an asynchronous subscription function. - /// The type. + /// The element type of the created sequence. /// The asynchronous subscription function. /// An observable sequence backed by the asynchronous subscription. /// is . @@ -43,7 +40,7 @@ public static IObservable Create(Func, Task> sub } /// Creates an observable from a cancellable asynchronous subscription function. - /// The type. + /// The element type of the created sequence. /// The asynchronous subscription function. /// An observable sequence backed by the asynchronous subscription. /// is . @@ -58,10 +55,7 @@ public static IObservable Create(Func, CancellationToken, Tas return new AsyncCreateSignal(subscribe); } - /// - /// Creates a signal that runs the subscribe function for each observer. A downstream OnNext that throws - /// leaves the subscription intact, which suits hot signals built over operators and events. - /// + /// Runs the subscription factory for each observer and preserves the subscription when a downstream OnNext throws. /// The element type. /// Invoked for each observer; returns the disposable that releases the subscription. /// Whether subscription must be dispatched through the current-thread sequencer. diff --git a/src/Primitives.Shared/Signals/Signal{Factories}.cs b/src/Primitives.Shared/Signals/Signal{Factories}.cs index ba4f3e45..c5d07b43 100644 --- a/src/Primitives.Shared/Signals/Signal{Factories}.cs +++ b/src/Primitives.Shared/Signals/Signal{Factories}.cs @@ -90,7 +90,7 @@ public static IObservable Unfold( return new UnfoldSignal(initialState, condition, iterate, resultSelector); } - /// Generates a finite signal from state. Alias of . + /// Generates a finite signal by advancing state while the condition holds. /// The type of the state. /// The type of the result. /// The initial state. diff --git a/src/Primitives.Shared/Signals/Signal{FromTask}.cs b/src/Primitives.Shared/Signals/Signal{FromTask}.cs index 161c63ec..0428db9f 100644 --- a/src/Primitives.Shared/Signals/Signal{FromTask}.cs +++ b/src/Primitives.Shared/Signals/Signal{FromTask}.cs @@ -230,7 +230,7 @@ private static void Cancel(CancellationTokenSource source) } catch (ObjectDisposedException) { - // Another completion path released the token source. + // Cancellation remains harmless after task completion. } } diff --git a/src/Primitives.Shared/Signals/TaskSignal{T}.cs b/src/Primitives.Shared/Signals/TaskSignal{T}.cs index cfd1e872..35b00d1a 100644 --- a/src/Primitives.Shared/Signals/TaskSignal{T}.cs +++ b/src/Primitives.Shared/Signals/TaskSignal{T}.cs @@ -97,7 +97,7 @@ private void Dispose(bool disposing) } catch (ObjectDisposedException) { - // The token source can be disposed by the task completion path. + // Cancellation remains harmless after task completion. } _cleanUp.Dispose(); diff --git a/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs b/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs index b43d4a94..225ed040 100644 --- a/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs +++ b/src/Primitives.Shared/Signals/TaskTerminalCompletion{T}.cs @@ -56,7 +56,6 @@ internal void FailEmpty() => /// The terminal task. internal Task Attach(IDisposable subscription, CancellationToken cancellationToken) { - // Writing the subscription before registering publishes it to the thread that runs the cancellation callback. _subscription = subscription; if (_completion.Task.IsCompleted) { @@ -71,8 +70,6 @@ internal Task Attach(IDisposable subscription, CancellationToken cancellation static state => ((TaskTerminalCompletion)state!).Cancel(), this); - // A source that completed while the registration was being created saw a default registration in the - // observer callbacks, so release the real one here. if (_completion.Task.IsCompleted) { _ = _cancellationRegistration.Unregister(); diff --git a/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs b/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs index a464807f..76b9a695 100644 --- a/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/CancellationDisposable.cs @@ -41,7 +41,7 @@ public CancellationDisposable() /// Cancels the source on the first call; repeated calls have no further effect. public void Dispose() { - // Atomic run-once latch so concurrent disposal cannot cancel the source twice. + // Concurrent disposal cancels the source at most once. if (Interlocked.Exchange(ref _isDisposed, 1) != 0) { return; diff --git a/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs b/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs index 92d07b89..0a6fd70b 100644 --- a/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/EmptyDisposable.cs @@ -24,6 +24,6 @@ private EmptyDisposable() /// public void Dispose() { - // Intentionally empty: represents the absence of a resource, so there is nothing to release. + // Disposing an empty resource has no effect. } } diff --git a/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs b/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs index 96268934..9baaa337 100644 --- a/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/MultipleDisposable.cs @@ -8,11 +8,6 @@ namespace ReactiveUI.Primitives.Disposables; /// A disposable pocket that contains a set of disposables and disposes them together. -/// -/// Implements over so it can stand in for a -/// composite disposable: it supports collection initializers, membership queries, and -/// DisposeWith-style extension methods that accept an . -/// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public class MultipleDisposable : IsDisposed, ICollection { @@ -86,7 +81,7 @@ public MultipleDisposable(params IDisposable[] disposables) /// Gets a value indicating whether the object is disposed. public bool IsDisposed => Volatile.Read(ref _disposed); - /// Gets the number of disposables currently held. Returns zero once disposed. + /// Gets the number of held disposables, or zero after disposal. public int Count { get @@ -114,7 +109,7 @@ public int Count } } - /// Gets a value indicating whether the collection is read-only. Always . + /// Gets a value indicating whether the collection is read-only, which is always false. public bool IsReadOnly => false; /// Gets the debugger display text. diff --git a/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs b/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs index d5da0447..2228848b 100644 --- a/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/MutableDisposable.cs @@ -6,12 +6,8 @@ namespace ReactiveUI.Primitives.Disposables; -/// -/// A disposable holder whose inner disposable can be re-assigned. The displaced inner -/// disposable is NOT disposed when replaced (in contrast to ). -/// Once this object is disposed, any inner disposable assigned afterwards is disposed -/// immediately. -/// +/// Holds a replaceable disposable without disposing the displaced value. +/// Assignments after disposal are disposed immediately. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class MutableDisposable : IsDisposed { diff --git a/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs b/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs index ac3ad4f7..6e572e77 100644 --- a/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs @@ -4,12 +4,8 @@ namespace ReactiveUI.Primitives.Disposables; -/// -/// A disposable holder whose inner disposable can be set exactly once. A second -/// assignment throws ; if the holder was disposed -/// before the assignment, the supplied disposable is disposed immediately and no exception -/// is thrown. -/// +/// Holds a disposable that can be assigned once. +/// Repeated assignment throws InvalidOperationException; an assignment after disposal is disposed immediately. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class OnceDisposable : IsDisposed { @@ -25,7 +21,7 @@ public sealed class OnceDisposable : IsDisposed /// Gets a value indicating whether this instance has been disposed. public bool IsDisposed => ReferenceEquals(Volatile.Read(ref _current), DisposedSentinel); - /// Gets or sets the inner disposable. Setting more than once throws. + /// Gets or sets the inner disposable, throwing on repeated assignment. public IDisposable? Disposable { get @@ -75,7 +71,7 @@ private sealed class DisposedMarker : IDisposable /// public void Dispose() { - // Intentionally empty. + // Disposing the terminal marker has no effect. } } } diff --git a/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs b/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs index 76b1303d..54df8768 100644 --- a/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/SingleDisposable.cs @@ -103,8 +103,7 @@ private sealed class DisposedMarker : IDisposable /// public void Dispose() { - // Intentionally empty: a reference-identity sentinel marking a disposed slot. It is only ever - // compared with ReferenceEquals and never itself disposed, so this body is unreachable. + // Disposing the terminal marker has no effect. } } } diff --git a/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs b/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs index 65fc271b..9c4dabe1 100644 --- a/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/SingleReplaceableDisposable.cs @@ -57,25 +57,7 @@ public SingleReplaceableDisposable(IDisposable disposable, Action? action) public void Create(IDisposable disposable) { ArgumentExceptionHelper.ThrowIfNull(disposable); - - while (true) - { - var current = Volatile.Read(ref _disposable); - if (ReferenceEquals(current, DisposedSentinel)) - { - disposable.Dispose(); - _action?.Invoke(); - return; - } - - if (!ReferenceEquals(Interlocked.CompareExchange(ref _disposable, disposable, current), current)) - { - continue; - } - - current?.Dispose(); - return; - } + CreateWithRetry(disposable); } /// Disposes the inner value and blocks further assignments; repeated calls have no further effect. @@ -85,6 +67,28 @@ public void Dispose() GC.SuppressFinalize(this); } + /// Replaces the observed value, or disposes the incoming value when the slot is closed. + /// The observed slot value. + /// The incoming disposable. + /// True when the incoming value was handled; false when the observed slot was stale. + internal bool TryCreate(IDisposable? current, IDisposable disposable) + { + if (ReferenceEquals(current, DisposedSentinel)) + { + disposable.Dispose(); + _action?.Invoke(); + return true; + } + + if (!ReferenceEquals(Interlocked.CompareExchange(ref _disposable, disposable, current), current)) + { + return false; + } + + current?.Dispose(); + return true; + } + /// Disposes the inner value and then invokes the constructor-supplied action, once. /// when invoked from . protected virtual void Dispose(bool disposing) @@ -99,6 +103,20 @@ protected virtual void Dispose(bool disposing) _action?.Invoke(); } + /// Retries replacement until the observed slot is current. + /// The incoming disposable. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void CreateWithRetry(IDisposable disposable) + { + while (true) + { + if (TryCreate(Volatile.Read(ref _disposable), disposable)) + { + return; + } + } + } + /// Disposable marker for disposed slots. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private sealed class DisposedMarker : IDisposable @@ -106,8 +124,7 @@ private sealed class DisposedMarker : IDisposable /// public void Dispose() { - // Intentionally empty: a reference-identity sentinel marking a disposed slot. It is only ever - // compared with ReferenceEquals and never itself disposed, so this body is unreachable. + // Disposing the terminal marker has no effect. } } } diff --git a/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs b/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs index 2d4aeb80..ce8057af 100644 --- a/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/SwapDisposable.cs @@ -6,12 +6,8 @@ namespace ReactiveUI.Primitives.Disposables; -/// -/// A disposable holder whose inner disposable can be re-assigned. The displaced inner -/// disposable is disposed when replaced (in contrast to ). -/// Once this object is disposed, any inner disposable assigned afterwards is disposed -/// immediately. -/// +/// Holds a replaceable disposable and disposes each displaced value. +/// Assignments after disposal are disposed immediately. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class SwapDisposable : IsDisposed { @@ -24,7 +20,7 @@ public sealed class SwapDisposable : IsDisposed /// Gets a value indicating whether this instance has been disposed. public bool IsDisposed => Volatile.Read(ref _disposed) == DisposableSlotHelper.DisposedSentinel; - /// Gets or sets the current inner disposable. Setting disposes the previous value. + /// Gets or sets the current inner disposable, disposing the previous value on assignment. public IDisposable? Disposable { get => Volatile.Read(ref _current); diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs index 43006eba..921dcb16 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/AsyncSerialGate.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// Serializes asynchronous critical sections with disposable leases. Reentrant acquisition is allowed only on the managed thread that currently owns the gate. +/// Serializes asynchronous critical sections, permitting reentry only from the owning managed thread. [System.Diagnostics.DebuggerDisplay("AsyncSerialGate: OwnerThreadId = {_ownerThreadId}, Waiters = {_waiters}, RecursionDepth = {_recursionDepth}")] public sealed class AsyncSerialGate : IDisposable { @@ -75,7 +75,7 @@ internal ValueTask EnterForThreadAsync(int currentThreadId, CancellationT : WaitForEntryAsync(cancellationToken); } - /// Exits the gate. Decrements the recursion depth on a nested exit, or clears the owner and signals one waiter (if any) on the outermost release. + /// Releases one acquisition, waking a waiter after the outermost release. internal void Exit() { if (_recursionDepth > 0) @@ -98,8 +98,6 @@ internal async ValueTask WaitForEntryAsync(CancellationToken cancellation { while (true) { - // Retry the CAS before waiting; closes the race where the owner releases between - // the caller's fast-path failure and our increment of _waiters. if (Interlocked.CompareExchange(ref _ownerThreadId, Environment.CurrentManagedThreadId, 0) == 0) { return new(this); diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs index b3611bb8..abc4c553 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/BackgroundJobSignal{T}.cs @@ -13,7 +13,7 @@ public sealed class BackgroundJobSignal : IObservableAsync /// The job to execute for each subscription. /// A value indicating whether the job starts synchronously on subscribe. /// The scheduler that starts the job, or to start it on - /// the thread pool after a yield. + /// the current context after yielding. public BackgroundJobSignal( Func, CancellationToken, ValueTask> job, bool startSynchronously, @@ -59,6 +59,7 @@ ValueTask IObservableAsync.SubscribeAsync( /// The observer receiving job notifications. /// The cancellation token for the job. /// A task representing the job. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private async ValueTask ExecuteAfterYieldAsync(IObserverAsync observer, CancellationToken cancellationToken) { await Task.Yield(); @@ -70,6 +71,7 @@ private async ValueTask ExecuteAfterYieldAsync(IObserverAsync observer, Cance /// The scheduler that runs the job. /// The cancellation token for the job. /// A task representing the job. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private async ValueTask ExecuteOnSchedulerAsync( IObserverAsync observer, TaskScheduler taskScheduler, diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs index be99c208..1ffdadf7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/CallbackWitnessAsync.cs @@ -4,8 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// A witness that routes notifications through caller-supplied asynchronous callbacks. A callback left -/// unset reports resumable errors and terminal failures to the unhandled exception handler. +/// Routes notifications to callbacks and unhandled failures to the exception handler. /// The type of the elements received by the witness. /// The asynchronous function invoked for each element. /// An optional asynchronous function invoked when a resumable error occurs. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs index 4e1a6b1f..3c7f3f2a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/FlatMapCoordinator{TResult}.cs @@ -53,7 +53,7 @@ public FlatMapCoordinator(IObserverAsync observer) /// Gets or sets a value indicating whether this coordinator has been disposed. private bool Disposed { get; set; } - /// Takes ownership of the outer subscription, disposing it immediately when this coordinator has finished. Callable once per coordinator. + /// Accepts the outer subscription once, disposing it immediately if this coordinator has finished. /// The outer subscription to own. /// A task that completes once the subscription has been stored or disposed. /// Thrown when an outer subscription is set twice. @@ -61,8 +61,7 @@ public FlatMapCoordinator(IObserverAsync observer) public ValueTask SetOuterObserverAsync(IAsyncDisposable observer) => OuterObserver.SetDisposableAsync(observer); - /// Cancels this coordinator's lifetime when is cancelled. A token that - /// cannot be cancelled is ignored; a token cancelled at call time cancels the lifetime immediately. + /// Links external cancellation to this lifetime, immediately honoring an already cancelled token. /// The subscribe-time cancellation token. public void LinkExternalCancellation(CancellationToken external) { @@ -86,8 +85,7 @@ public void LinkExternalCancellation(CancellationToken external) [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask DisposeAsync() => FinishAsync(null); - /// Subscribes to an inner sequence and counts it as active until it completes. A failure raised while - /// subscribing finishes the whole sequence with that failure; a call after teardown is a no-op. + /// Tracks an inner subscription until completion, terminating the sequence if subscribing fails. /// The inner sequence. /// A task that completes once the inner sequence has been subscribed. public async ValueTask SubscribeInnerAsync(IObservableAsync inner) @@ -117,7 +115,7 @@ public async ValueTask SubscribeInnerAsync(IObservableAsync inner) } /// Forwards an inner value downstream, serialized against the other inner sequences so the observer is never entered concurrently. - /// The value. + /// The inner value to forward. /// A task that completes once the observer has accepted the value. public async ValueTask RelayNextAsync(TResult value) { @@ -172,7 +170,7 @@ public ValueTask CompleteInnerAsync(Result result) return shouldComplete ? FinishAsync(result) : default; } - /// Completes the downstream observer and disposes every tracked subscription. Idempotent; a failure handed in after teardown goes to the unhandled exception handler. + /// Completes and releases this coordinator once, reporting subsequent failures to the unhandled exception handler. /// The result to forward, or to tear down without completing /// the observer. /// A task that completes once teardown has finished. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs index 4bb02c4e..b94d55a9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/ForwardingWitnessAsync.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// Base observer that forwards every notification unchanged to a downstream observer. A derived type -/// overrides only the notifications it needs to intercept and inherits pass-through behaviour for the rest. -/// +/// Forwards notifications unchanged unless a derived observer overrides them. /// The observed element type. [System.Diagnostics.DebuggerDisplay("ForwardingWitnessAsync: Downstream = {Downstream}")] public class ForwardingWitnessAsync : WitnessAsync diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs index 01b0b253..2e535962 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSignal.cs @@ -4,8 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// An observable that emits incrementing ticks at a fixed interval. Ticks are numbered from one, each -/// emitted after a further period has elapsed, and continue until the subscription is disposed. +/// Emits ticks numbered from one, waiting one period between notifications until disposal. [System.Diagnostics.DebuggerDisplay("IntervalSignal: Period = {Period}, TimeProvider = {TimeProvider}")] public sealed class IntervalSignal : IObservableAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs index 24b95d8b..1dc3b05d 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/IntervalSubscription.cs @@ -36,7 +36,7 @@ protected override async ValueTask ExecuteAsyncCore( { if (TimeProvider is null || TimeProvider == TimeProvider.System) { - await Task.Delay(Period, cancellationToken).ConfigureAwait(false); + await SignalAsyncExtensions.DelayAsync(Period, TimeProvider.System, cancellationToken).ConfigureAwait(false); } else { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs index 63f4e692..bb93e2e4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/LeadSubscription{T}.cs @@ -99,7 +99,7 @@ private async Task RunAsync() } catch (OperationCanceledException) { - // Cooperative subscription cancellation. + // Subscription cancellation is not reported as a failure. } catch (Exception e) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs index 7be36594..109501e6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/PooledDelaySource.cs @@ -8,12 +8,8 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// Poolable backing DelayAsync for non-System instances, so a delay costs no per-call allocation. -/// -/// Whichever of the timer callback and the cancellation registration fires first claims completion -/// through an on a state flag; the -/// loser is a no-op. -/// +/// Provides reusable cancellable delays driven by a supplied time provider. +/// The first timer or cancellation callback determines the result. [System.Diagnostics.DebuggerDisplay("PooledDelaySource: Completed = {_completed}, Timer = {_timer}")] public sealed class PooledDelaySource : IValueTaskSource { @@ -27,7 +23,7 @@ public sealed class PooledDelaySource : IValueTaskSource [ThreadStatic] private static PooledDelaySource? _threadCached; - /// Backing source. Continuations run asynchronously so awaiters never re-enter the timer / cancel path. + /// The completion source, whose continuations run asynchronously. private ManualResetValueTaskSourceCore _core = new() { RunContinuationsAsynchronously = true }; /// Tracks completion: until the timer or cancellation claims completion; then . @@ -58,15 +54,12 @@ public static PooledDelaySource Rent() return cached; } - /// - /// Begins the delay. The returned completes when the timer fires or - /// the cancellation token is signalled — whichever happens first. The caller MUST await it - /// exactly once; the instance returns to the pool inside . - /// + /// Begins a delay that completes when its timer or cancellation fires first. /// The dueTime passed to . /// The non-System time provider supplying the timer. /// Cancellation token observed while waiting. /// A backed by this source. + /// Await the returned value exactly once; consuming its result returns this source to the pool. public ValueTask BeginAsync(TimeSpan delay, TimeProvider timeProvider, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) @@ -76,8 +69,6 @@ public ValueTask BeginAsync(TimeSpan delay, TimeProvider timeProvider, Cancellat return new(this, _core.Version); } - // CreateTimer may invoke the callback synchronously, flipping _completed to Claimed before - // this call returns. _timer = timeProvider.CreateTimer( static state => ((PooledDelaySource)state!).OnTimerFired(), this, @@ -86,7 +77,6 @@ public ValueTask BeginAsync(TimeSpan delay, TimeProvider timeProvider, Cancellat if (Volatile.Read(ref _completed) == StateClaimed) { - // Sync-fire fast path: the source is complete, so no cancellation registration is needed. return new(this, _core.Version); } @@ -159,7 +149,6 @@ private void ReturnToPool() _completed = StateOpen; _core.Reset(); - // One instance cached per thread; any extra instances are dropped for the GC. _threadCached ??= this; } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/ReattemptSubscription{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/ReattemptSubscription{T}.cs index da8bcd80..4ac57321 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/ReattemptSubscription{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/ReattemptSubscription{T}.cs @@ -61,7 +61,7 @@ public async ValueTask SubscribeOnceAsync() } catch (OperationCanceledException) { - // Cooperative subscription cancellation. + // Subscription cancellation is not reported as a failure. } catch (Exception e) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SequenceSubscription.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SequenceSubscription.cs index 7846b700..044f0d00 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SequenceSubscription.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SequenceSubscription.cs @@ -34,7 +34,6 @@ protected override async ValueTask ExecuteAsyncCore( { cancellationToken.ThrowIfCancellationRequested(); - // Keep the observer notification token-free on the hot path; the loop checks the subscription token. await observer.OnNextAsync(StartValue + i, CancellationToken.None).ConfigureAwait(false); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs index a784cc14..a5b59c45 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/StartSubscription{TResult}.cs @@ -43,7 +43,21 @@ protected override async ValueTask ExecuteAsyncCore( return; } - await Task.Factory.StartNew( + await ExecuteOnSchedulerAsync(observer, taskScheduler, cancellationToken).ConfigureAwait(false); + } + + /// Starts the function through the supplied scheduler. + /// The observer receiving the produced value. + /// The scheduler that starts the function. + /// Cancellation for the scheduled task and notifications. + /// The scheduled function and notification operation. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private Task ExecuteOnSchedulerAsync( + IObserverAsync observer, + TaskScheduler taskScheduler, + CancellationToken cancellationToken) => + Task.Factory.StartNew( static s => { var (self, observer, cancellationToken) = @@ -54,9 +68,7 @@ await Task.Factory.StartNew( cancellationToken, TaskCreationOptions.DenyChildAttach, taskScheduler) - .Unwrap() - .ConfigureAwait(false); - } + .Unwrap(); /// Invokes the function and forwards its result to the observer. /// The observer receiving the produced value. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs index 5eacb0fe..e8142c97 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestCoordinatorBase.cs @@ -55,7 +55,7 @@ internal ValueTask RelaySourceErrorAsync(Exception error, CancellationToken canc return Lifecycle.OnErrorResumeAsync(error); } - /// Projects the latest values once every source has emitted. Called after updating a slot under ValuesLock. + /// Projects the latest values under ValuesLock once every source has emitted. /// A ValueTask representing the asynchronous emit. internal abstract ValueTask EmitLatestAsync(); diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs index 7138fc4f..88d0e402 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestEnumerableCoordinator{TSource,TResult}.cs @@ -99,7 +99,7 @@ public async ValueTask SubscribeSourcesAsync(CancellationToken cancellationToken /// Records the source's latest value and emits the projected snapshot once every source has produced one. /// The source index. - /// The value. + /// That source's latest value. /// The cancellation token. /// A value task representing the operation. internal async ValueTask OnNextAsync(int index, TSource indexValue, CancellationToken cancellationToken) @@ -162,7 +162,7 @@ internal async ValueTask OnErrorResumeAsync(Exception error, CancellationToken c /// Completes the combined sequence when the source failed, completed without a value, or was the last to complete. /// The source index. - /// The result. + /// That source's completion result. /// A value task representing the operation. internal ValueTask OnCompletedAsync(int index, Result result) { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs index 81bbcb87..a884d656 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestIndexedWitness.cs @@ -4,12 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// Per-source for a CombineLatestN subscription: records each -/// value into the parent's typed slot under the values-lock, then asks the parent to emit. One -/// instance is built per source at subscribe time, so the per-emission cost is a single delegate -/// invoke. -/// +/// Updates one source's latest value and requests a combined notification. /// The element type of the upstream source this witness subscribes to. /// The downstream element type owned by the parent subscription. /// The parent subscription that owns the values-lock and lifecycle. diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs index c9ab8df4..2563fadc 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/SyncLatestLifecycle.cs @@ -30,7 +30,7 @@ public sealed class SyncLatestLifecycle : IAsyncDisposable /// Bitmask value with every source-completion bit set; the sequence completes when equals this value. private readonly int _allDoneMask; - /// Bitmask of completed sources. Bit N is set when source N completes (no failure). + /// Bitmask of sources that completed successfully. private int _doneFlags; /// Set once disposal has begun, via . @@ -114,11 +114,7 @@ public async ValueTask EmitDownstreamAsync(TResult value) } } - /// - /// Records completion of a single source. If the source failed, completes the combined sequence - /// with the failure; otherwise sets the matching and, once every - /// source bit is set, completes the combined sequence successfully. - /// + /// Completes the combined sequence on any failure or after every source succeeds. /// The completion result from the upstream source. /// The bitmask bit owned by the completing source (1 << index). /// A ValueTask representing the asynchronous handler. @@ -174,8 +170,7 @@ public async ValueTask FinishAsync(Result? result) } finally { - // A throwing upstream DisposeAsync or OnCompletedAsync must not leak the gate's - // SemaphoreSlim or the dispose CTS's wait handles. + // Cleanup completes even when completion or upstream disposal throws. #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER await _externalLinkRegistration.DisposeAsync().ConfigureAwait(false); #else diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs index 5de76507..4a36eea7 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TakeUntilLifecycle.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; -/// -/// Subscription lifecycle for the TakeUntil family of operators (CancellationToken, Task, -/// raw-signal, async-observable and async-predicate triggers): owns the serialization gate, the -/// dispose cancellation source, the external-link registration and the gated observer fan-out. -/// +/// Serializes TakeUntil notifications and links their lifetime to subscription cancellation. /// The downstream element type. [System.Diagnostics.DebuggerDisplay("TakeUntilLifecycle: Observer = {_observer}, DisposeRequested = {DisposeToken.IsCancellationRequested}")] public sealed class TakeUntilLifecycle : IAsyncDisposable @@ -92,11 +88,7 @@ public async ValueTask RelayCompletionAsync(Result result) } } - /// - /// Cancels the dispose token, releases the external-link registration and disposes the gate. The - /// owning subscription disposes its own per-operator handles (source subscription, token - /// callback) separately, in whichever order that operator requires. - /// + /// Cancels notifications and releases cancellation registration and serialization resources. /// A ValueTask representing the asynchronous teardown. public async ValueTask DisposeAsync() { diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs index 4a5d7a63..84c262e4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskSignalSubscription{T}.cs @@ -7,8 +7,7 @@ namespace ReactiveUI.Primitives.Async.Advanced; /// A subscription that runs a cancellable asynchronous job feeding a single observer, and joins that job on disposal. /// The type of the elements observed by the subscription. /// The observer that receives notifications for the subscription. Cannot be null. -/// Disposal cancels the running job and waits for it to finish before releasing resources; -/// derived classes supply the job body in . +/// Disposal waits for the cancelled job except when called from inside that job. [System.Diagnostics.DebuggerDisplay("TaskSignalSubscription: Disposed = {_disposed}, Completed = {_tcs.Task.IsCompleted}")] public abstract class TaskSignalSubscription(IObserverAsync observer) : IAsyncDisposable { @@ -49,10 +48,7 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } - /// - /// Attempts to complete the observer with a failure result. If the observer's completion handler - /// also throws, the exception is routed to . - /// + /// Forwards a failure result, reporting a throwing completion handler to the unhandled exception handler. /// The observer to complete. /// The original exception. /// A representing the operation. diff --git a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs index 885d4ee3..409d38f6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/ConnectableSignalAsyncHelper.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Async.Advanced; using ReactiveUI.Primitives.Async.Disposables; @@ -22,7 +23,6 @@ internal static async ValueTask ConnectAsync( ConnectableSignalAsyncState state, CancellationToken cancellationToken) { - // The gate's fast path does not check cancellation; reject disposed signals before entering. state.DisposedCancellationToken.ThrowIfCancellationRequested(); CancellationTokenSource? linkedCts = null; @@ -81,10 +81,6 @@ internal static async ValueTask ConnectAsync( /// Disposes the connection state and releases its gate resources. /// The type of elements produced by the source sequence. /// The connectable signal state to dispose. - [SuppressMessage( - "Concurrency", - "PSH1315:A blocking wait on an awaitable that may not be done", - Justification = "The synchronous dispose contract leaves no way to await teardown of the async connection state.")] internal static void Dispose(ConnectableSignalAsyncState state) { if (!state.TryMarkDisposed()) @@ -93,7 +89,11 @@ internal static void Dispose(ConnectableSignalAsyncState state) } state.DisposedCts.Cancel(); - state.Connection?.DisposeAsync().AsTask().Wait(CancellationToken.None); + if (state.Connection is { } connection) + { + WaitForDisposal(connection.DisposeAsync().AsTask()); + } + state.Dispose(); } @@ -116,4 +116,14 @@ internal static ValueTask SubscribeAsync( return state.Signal.Values.SubscribeAsync(wrap, cancellationToken); } + + /// Waits synchronously for connection disposal to finish. + /// The connection's disposal task. + [ExcludeFromCodeCoverage] + [SuppressMessage( + "Concurrency", + "PSH1315:A blocking wait on an awaitable that may not be done", + Justification = "The synchronous dispose contract leaves no way to await teardown of the async connection state.")] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WaitForDisposal(Task completion) => completion.Wait(CancellationToken.None); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs index 7eb311f4..b4ebec09 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsync.cs @@ -25,11 +25,7 @@ public static IAsyncDisposable Create(Func disposeAsync) return new DelegateAsyncDisposable(disposeAsync); } - /// - /// Creates a new asynchronous disposable that invokes the specified delegate, passing the supplied state, when - /// disposed asynchronously. Preferred over wherever the lambda would capture - /// locals or this: passing them as state lets the lambda be static and costs no closure. - /// + /// Creates a disposable that passes explicit state to its cleanup delegate. /// The type of the state passed to the dispose delegate. /// The state forwarded to at dispose time. /// The dispose delegate. Must not be null. diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs index aa266e5a..0b6359d8 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/DisposableAsyncSlot.cs @@ -7,21 +7,13 @@ namespace ReactiveUI.Primitives.Async.Disposables; -/// -/// Zero-allocation static helpers that implement -style swap -/// and -style single-assignment semantics directly -/// against a caller-owned field. Use these when the wrapper-class -/// allocation that the convenience types incur is on a hot path. -/// +/// Manages atomic replacement, single assignment, and disposal of a caller-owned resource slot. public static class DisposableAsyncSlot { /// Shared marker used by all async-disposable slot implementations once a slot is closed. internal static readonly IAsyncDisposable DisposedSentinel = new DisposedAsyncDisposable(); - /// Swaps the slot's current contents with and asynchronously - /// disposes the previous occupant. Equivalent to - /// , but operates on a caller-owned field - /// so no wrapper instance is allocated. + /// Replaces the slot's resource and asynchronously disposes the previous value. /// Reference to the caller-owned field. /// The new value to store, or to clear the slot. /// A that completes once the previous occupant (if any) has been disposed. @@ -30,9 +22,7 @@ public static class DisposableAsyncSlot public static ValueTask SwapAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value) => SwapObservedAsync(ref slot, value, Volatile.Read(ref slot)); - /// Atomically fills an empty slot with . A closed slot disposes - /// instead. Equivalent to - /// . + /// Assigns an empty slot once, disposing the supplied value if the slot is closed. /// Reference to the caller-owned field. /// The value to assign, or . /// A that completes once has been disposed when the slot @@ -52,9 +42,7 @@ public static ValueTask AssignAsync(ref IAsyncDisposable? slot, IAsyncDisposable : throw CreateAlreadyAssignedException(); } - /// Asynchronously disposes the slot's current contents and marks the slot as disposed. - /// Subsequent / calls will dispose their incoming - /// value rather than store it. Idempotent. + /// Closes and disposes the slot once, causing future assignments to dispose their incoming values. /// Reference to the caller-owned field. /// A that completes once the prior occupant has been disposed. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs index 17b7c488..6407029c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/MultipleDisposableAsync.cs @@ -33,7 +33,7 @@ public sealed class MultipleDisposableAsync : IAsyncDisposable /// Backing array; a removal zeroes its slot, so is the high-water mark and the non-null slots. private IAsyncDisposable?[]? _items; - /// High-water mark of used slots in . Includes slots zeroed by Remove. + /// The used array length, including gaps left by removed items. private int _length; /// The number of non- disposables in the collection. @@ -73,11 +73,7 @@ public MultipleDisposableAsync(params IAsyncDisposable[] disposables) _count = disposables.Length; } - /// - /// Initializes a new instance of the class that contains the specified - /// disposables. The backing array is sized exactly when implements - /// ; otherwise it grows from the default capacity. - /// + /// Initializes a new instance of the class containing the supplied disposables. /// The collection of instances to include. public MultipleDisposableAsync(IEnumerable disposables) { @@ -250,7 +246,7 @@ public bool Contains(IAsyncDisposable item) /// The index in at which copying begins. /// falls outside /// , or the space from it to the end of the array cannot hold every item. - /// A disposed collection copies nothing and raises nothing. + /// A disposed collection copies nothing, but destination index validation still applies. public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) { if (arrayIndex < 0 || arrayIndex >= array?.Length) @@ -281,7 +277,7 @@ public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) /// Asynchronously releases all resources used by the collection and disposes of each contained asynchronous disposable object. /// A task that represents the asynchronous dispose operation. - /// Idempotent. Items are disposed one after another in insertion order. + /// Disposes items once, sequentially in insertion order. public async ValueTask DisposeAsync() { IAsyncDisposable?[]? snapshot; @@ -373,7 +369,7 @@ private void CopyToCore(IAsyncDisposable[] array, int arrayIndex) } } - /// Ensures has at least one free slot at index . Allocates the default-capacity array on first use; doubles on subsequent overflow. + /// Grows the array when no unused slot remains. private void EnsureCapacityForOneMore() { if (_items is null) @@ -392,7 +388,7 @@ private void EnsureCapacityForOneMore() _items = grown; } - /// Removes null gaps inside and shrinks the backing array to half its capacity. Caller must hold . + /// Compacts and halves the array while the caller holds the gate. private void CompactInPlace() { var src = _items!; diff --git a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs index d9dc6ad0..7526153f 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Disposables/SingleReplaceableDisposableAsync.cs @@ -7,8 +7,8 @@ namespace ReactiveUI.Primitives.Async.Disposables; /// Owns one asynchronous disposable at a time, replacing it on assignment. -/// Exactly one resource is live at a time: each assignment disposes the one it displaces, and disposing this -/// instance closes the slot so a further assignment disposes its argument. Safe to use from several threads. +/// Assignments await disposal of the replaced resource; assignments after disposal release their incoming resource. +/// Concurrent calls are supported. [System.Diagnostics.DebuggerDisplay("SingleReplaceableDisposableAsync: Current = {_current}")] public class SingleReplaceableDisposableAsync : IAsyncDisposable { diff --git a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs index 552c90ba..447fb89c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Helpers/FireAndForgetHelper.cs @@ -39,7 +39,7 @@ internal static async ValueTask RunAsync(Func action) } catch (Exception e) { - // Fire-and-forget has no caller to propagate to; surface the failure to the global handler. + // Detached-operation failures reach the global handler. UnhandledExceptionHandler.ReportUnhandledException(e); } } diff --git a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs index 89864d45..4b04a432 100644 --- a/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/IObserverAsync.cs @@ -6,8 +6,7 @@ namespace ReactiveUI.Primitives.Async; /// Defines an asynchronous observer that receives notifications about a sequence of values, completion, or errors, and supports asynchronous resource cleanup. /// The type of the elements received by the observer. -/// Every notification is awaitable and cancellable, so a producer that awaits them gets backpressure for -/// free: the observer's handler has to finish before the next value is pushed. +/// Producers must await each notification before sending the next. public interface IObserverAsync : IAsyncDisposable { /// Signals that the sequence has terminated, successfully or with a failure. @@ -19,8 +18,7 @@ public interface IObserverAsync : IAsyncDisposable /// The exception to report. /// A token that cancels the observer's handling of the error. /// A task that completes when the observer has handled the error. - /// Unlike a faulted , this does not end the sequence; an implementation - /// chooses whether to swallow the error or tear itself down. + /// The observer may handle the error and continue receiving notifications or dispose its subscription. ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken); /// Delivers the next value in the sequence. diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs index 5d9cfade..7d90ae53 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Create.cs @@ -7,8 +7,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides factory methods for creating asynchronous observables and background jobs that emit values to observers asynchronously. -/// These are the entry points for turning an arbitrary asynchronous producer into a sequence: supply the -/// subscribe logic yourself, or hand over a job to run per subscriber. public static partial class SignalAsync { /// Creates a new asynchronous observable sequence using the specified subscription function. diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs index b36b582c..a981f041 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Empty.cs @@ -14,7 +14,6 @@ public static partial class SignalAsync /// Creates an observable sequence that completes immediately without emitting any items. /// The type of elements in the observable sequence. /// An observable sequence of type that completes immediately without producing any values. - /// The returned instance is a singleton per element type. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Design", diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs index 24376c6f..4a3dcc48 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Never.cs @@ -14,7 +14,6 @@ public static partial class SignalAsync /// Creates an observable sequence that never produces any values and never completes. /// The type of elements in the observable sequence. /// An observable sequence of type that never emits any items and never terminates. - /// The returned instance is a singleton per element type. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Design", diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs index ee4627d0..1dc61fcd 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/Return.cs @@ -13,8 +13,7 @@ public static partial class SignalAsync /// The type of the value to be emitted by the observable sequence. /// The value to be emitted by the observable sequence. /// An observable sequence that emits the specified value and then signals completion. - /// Subscribing returns before the value is observed: the emit and the completion are scheduled onto the - /// thread pool, so an observer must not assume it has seen the value by the time subscribe finishes. + /// Notification starts during subscription and may finish synchronously. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IObservableAsync Emit(T value) => new ReturnSignalAsync(value); diff --git a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs index cb240e00..631fb006 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Observables/ToAsyncSignal.cs @@ -8,8 +8,6 @@ namespace ReactiveUI.Primitives.Async; /// Provides extension methods for converting tasks, asynchronous enumerables, and enumerable sequences into asynchronous observable sequences. -/// Every overload is spelled ToAsyncSignal, so the conversion reads the same whatever the source, and a -/// sequence that is an passes straight through. public static partial class SignalAsyncExtensions { /// Observable-conversion operators for an asynchronous enumerable source. @@ -33,11 +31,10 @@ public static partial class SignalAsyncExtensions /// The enumerable sequence to convert to an asynchronous observable. Cannot be null. extension(IEnumerable source) { - /// Converts the specified enumerable sequence to an asynchronous observable sequence, emitting each element in the background. + /// Emits the enumerable's elements, awaiting each observer notification. /// An asynchronous observable sequence that emits each element from the source enumerable and completes when all /// elements have been emitted. - /// Enumeration runs on a background thread, once per subscriber, so a blocking or side-effecting - /// sequence neither stalls the subscribe call nor is shared between observers. + /// Enumeration starts on the subscribing thread, once per subscriber. [MethodImpl(MethodImplOptions.AggressiveInlining)] [SuppressMessage( "Roslynator", diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs index dcbd4006..ea33e5da 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Blend.cs @@ -39,8 +39,8 @@ public IObservableAsync Merge() => { /// Merges multiple asynchronous observable sequences into a single observable sequence that emits items from all inner sequences as they arrive. /// An asynchronous observable sequence that emits items from all inner observable sequences as they are produced. - /// Every inner sequence stays subscribed at once; the result completes only after the outer sequence - /// and all inner sequences have completed, and an error from any of them propagates and terminates it. + /// All inner sources remain subscribed; completion waits for the outer source and every inner source, + /// and any error terminates immediately. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Blend() => new BlendSignalSourcesSignal(source); @@ -390,7 +390,6 @@ internal override async ValueTask SubscribeBranchAsync(IObservableAsync inner } finally { - // The observer releases its semaphore slot once; failed subscriptions also dispose it here. if (!subscribed) { await innerObserver.DisposeAsync().ConfigureAwait(false); @@ -515,7 +514,6 @@ internal void BeginSubscribing() => FireAndForgetHelper.Run(async () => _reentrant.Value = true; try { - // Hold a sentinel while subscribing so synchronous sources cannot complete the merge early. _ = Interlocked.Increment(ref _active); foreach (var src in _sources) @@ -539,7 +537,6 @@ internal void BeginSubscribing() => FireAndForgetHelper.Run(async () => } } - // Remove sentinel: if all inner sources completed during the loop, this triggers final completion. if (Interlocked.Decrement(ref _active) == 0) { await FinishAsync(Result.Success).ConfigureAwait(false); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs index 82412d11..cebe2761 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Chain.cs @@ -17,8 +17,7 @@ public static partial class SignalAsyncExtensions /// Concatenates multiple asynchronous observable sequences into a single sequence that emits items from each source in order. /// An asynchronous observable sequence that emits all items from each source sequence in the order they appear in /// the collection. - /// A source is subscribed only after the previous one completes, so at most one source is active at a - /// time; an error from any source stops the concatenation and is propagated. + /// Sources run one at a time in sequence; any error propagates and terminates the result. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync Chain() => new ChainEnumerableSignal(sources); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs index e6bb0b31..abdb9ec6 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainEnumerableSignal{T}.cs @@ -112,7 +112,7 @@ internal async ValueTask SubscribeNextSignalAsync() /// A task representing the asynchronous operation. internal ValueTask RelayInnerErrorAsync(Exception exception, CancellationToken cancellationToken) { - // The inner subscription shares this disposal token. + // Notifications use subscription cancellation instead of the supplied token. _ = cancellationToken; return _observer.OnErrorResumeAsync(exception, _disposedCancellationToken); } @@ -127,7 +127,7 @@ internal ValueTask RelayInnerValueAsync(T value, CancellationToken cancellationT return _observer.OnNextAsync(value, _disposedCancellationToken); } - /// Disposes the inner subscription and enumerator, and optionally forwards a completion result to the downstream observer. This method is idempotent. + /// Disposes the inner subscription and enumerator once, optionally forwarding completion. /// The completion result to forward, or if disposing without signaling completion. /// A task representing the asynchronous operation. internal async ValueTask FinishAsync(Result? result) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs index cd5f496d..21757709 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ChainSignalSourcesSignal{T}.cs @@ -177,7 +177,7 @@ await currentInner.SubscribeAsync(new ChainInnerWitness(this), _disposedCancella } } - /// Disposes the inner and outer subscriptions and optionally forwards a completion result to the downstream observer. This method is idempotent. + /// Disposes the inner and outer subscriptions once, optionally forwarding completion. /// The completion result to forward, or if disposing without signaling completion. /// A task representing the asynchronous operation. internal async ValueTask FinishAsync(Result? result) @@ -219,7 +219,7 @@ protected override async ValueTask OnErrorResumeAsyncCore( Exception error, CancellationToken cancellationToken) { - // The outer subscription shares this disposal token. + // Notifications use subscription cancellation instead of the supplied token. _ = cancellationToken; var token = subscription._disposedCancellationToken; using (await subscription._observerOnSomethingGate.EnterAsync(token).ConfigureAwait(false)) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs index 67adf464..f9744f37 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Expire.cs @@ -14,10 +14,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Applies a time limit between elements. If the next element does not arrive within - /// , the sequence completes with a . - /// + /// Completes with a TimeoutException when the next element misses the deadline. /// The maximum time span allowed between consecutive elements. Must be positive. /// An observable sequence that mirrors the source but completes with a /// once an inter-element gap exceeds . @@ -84,7 +81,7 @@ internal void StartTimer(CancellationToken cancellationToken) } catch (Exception e) { - // Timer creation failure is reported; values continue without timeout enforcement. + // If timeout setup fails, values continue without timeout enforcement. UnhandledExceptionHandler.ReportUnhandledException(e); } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs index e6495cca..56352a9a 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Fold.cs @@ -64,8 +64,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { FoldAsyncWitness sink = new(observer, seed, accumulator, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so the downstream's hot path - // recognises this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); @@ -87,8 +85,7 @@ internal sealed class FoldAsyncWitness( Func> accumulator, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// The running accumulator state. Mutated only inside , which the - /// base observer serializes via its reentrancy gate, so no additional locking is required. + /// The accumulator state protected by the observer's notification gate. private TAcc _acc = seed; /// @@ -145,8 +142,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { FoldSyncWitness sink = new(observer, seed, accumulator, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so the downstream's hot path - // recognises this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); @@ -168,8 +163,7 @@ internal sealed class FoldSyncWitness( Func accumulator, CancellationToken subscribeToken) : WitnessAsync(subscribeToken) { - /// The running accumulator state. Mutated only inside , which the - /// base observer serializes via its reentrancy gate, so no additional locking is required. + /// The accumulator state protected by the observer's notification gate. private TAcc _acc = seed; /// diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs index 8c8a33c6..637368a9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Keep.cs @@ -83,8 +83,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { KeepAsyncWitness sink = new(observer, predicate, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so the downstream's hot path - // recognises this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); @@ -141,8 +139,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { KeepSyncWitness sink = new(observer, predicate, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so the downstream's hot path - // recognises this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs index 4eee32e5..de3ae39e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Map.cs @@ -89,8 +89,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { MapAsyncWitness sink = new(observer, selector, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so the downstream's hot path - // recognises this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); @@ -143,8 +141,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { MapSyncWitness sink = new(observer, selector, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so the downstream's hot path - // recognises this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs index f71f81a4..d857adaf 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Multicast.cs @@ -21,10 +21,7 @@ public static partial class SignalAsyncExtensions public ConnectableSignalAsync Multicast(ISignalAsync signal) => new(source, signal); - /// - /// Returns a connectable observable sequence that shares a single subscription to the underlying asynchronous - /// observable. Observers will receive all notifications published after they subscribe. - /// + /// Shares one source subscription, forwarding live notifications to connected observers. /// A connectable observable sequence that multicasts notifications to all subscribed observers. The sequence /// does not begin emitting items until its Connect method is called. /// Notifications are delivered to observers serially. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs index dfd2ff09..f89b8031 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Pair.cs @@ -23,8 +23,8 @@ public static partial class SignalAsyncExtensions /// An observable sequence whose elements are the result of pair-wise combining the source /// elements using the result selector. /// , or is . - /// The nth element of one source is paired with the nth element of the other, so an element waits until - /// its partner arrives. The result completes when either source completes with no pending pair. + /// Values wait for their partner at the same index; the result completes when either source completes + /// with no pending pair. public IObservableAsync Pair( IObservableAsync second, Func resultSelector) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs index 02a2fb54..00646b52 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.FilterFusions.cs @@ -139,8 +139,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { WhereIsNotNullWitness sink = new(observer, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so its hot path recognises - // this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs index f2b9b19c..b5700d26 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.OperatorFusions.cs @@ -313,7 +313,6 @@ private async Task FireAfterDelayAsync(T value, long id, CancellationToken cance } catch (Exception e) { - // ReportUnhandledException drops cancellation itself, so no separate catch for it. UnhandledExceptionHandler.ReportUnhandledException(e); } } @@ -514,7 +513,7 @@ protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancella { if (condition(value)) { - // Bypass path: cancel any pending delay and emit immediately. + // Bypass cancels pending delayed delivery and emits immediately. lock (_gate) { _id++; @@ -580,7 +579,6 @@ private async Task DelayAndEmitAsync(T value, long id, CancellationToken cancell } catch (Exception e) { - // ReportUnhandledException drops cancellation itself, so no separate catch for it. UnhandledExceptionHandler.ReportUnhandledException(e); } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs index dfcfe537..73577a64 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.Partition.cs @@ -237,7 +237,6 @@ internal sealed class PartitionBranchSignal(bool isTrueBranch) : IObservableAsyn ValueTask IObservableAsync.SubscribeAsync( IObserverAsync observer, CancellationToken cancellationToken) => - // The coordinator's constructor sets Coordinator on both branches, so it is never null here. Coordinator.SubscribeBranchAsync(isTrueBranch, observer, cancellationToken); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs index 89957229..82db74c4 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ParityHelpers.cs @@ -185,11 +185,7 @@ public IObservableAsync DropIfBusy(Func asyn return new DropIfBusySignal(source, asyncAction); } - /// - /// Emits the latest value or the provided default value before the source produces its first value. - /// If the first source value equals , it will be suppressed by the - /// distinct-until-changed filter. - /// + /// Starts with the default value and emits subsequent source values only when they change. /// The default value to emit first. /// A sequence that starts with the provided default value and then emits distinct source updates. public IObservableAsync LatestOrDefault(T defaultValue) @@ -230,7 +226,7 @@ public IObservableAsync WaitUntil(Func predicate) return new PairwiseSignal(source); } - /// Partitions the source sequence into values that satisfy the predicate and values that do not. The predicate is evaluated exactly once per element. + /// Partitions the source into matches and nonmatches, evaluating the predicate once per element. /// The partition predicate. /// A tuple of true and false partitions. public (IObservableAsync True, IObservableAsync False) Partition(Func predicate) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs index 29815e2d..868eac50 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Recover.cs @@ -80,8 +80,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { CatchWitness sink = new(observer, handler, onErrorResume, cancellationToken); - // Wire sink's dispose token into the downstream's link chain so the downstream's hot path - // recognises this token without allocating a per-emission linked CTS. if (observer is WitnessAsync downstreamBase) { downstreamBase.LinkUpstreamCancellation(sink.InternalDisposedToken); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs index c1660ae7..c4bbfd66 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/RefCount.cs @@ -81,7 +81,6 @@ async ValueTask IObservableAsync.SubscribeAsync( { using (await _gate.EnterAsync(cancellationToken).ConfigureAwait(false)) { - // Increment before subscribing: a source that completes during the subscribe decrements it again. ++_refCount; var needConnect = _refCount == 1; RefCountWitness refCountWitness = new(this, observer); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs index 62c90480..f838fc58 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Shift.cs @@ -12,7 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// Time-shifts the observable sequence by the specified time span. Each element notification is delayed by the specified duration. + /// Delays each element notification by the specified duration. /// The time span by which to delay each element notification. Must be non-negative. /// An observable sequence with element notifications time-shifted by the specified duration. /// Thrown if is negative. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs index b29b3292..a17b3508 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Delay.Rx.cs @@ -12,7 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// Time-shifts the observable sequence by the specified time span. Each element notification is delayed by the specified duration. + /// Delays each element notification by the specified duration. /// The time span by which to delay each element notification. Must be non-negative. /// An observable sequence with element notifications time-shifted by the specified duration. /// Thrown if is negative. @@ -25,7 +25,7 @@ public IObservableAsync Delay(TimeSpan delayInterval) : new DelaySignal(source, delayInterval, TimeProvider.System); } - /// Time-shifts the observable sequence by the specified time span. Each element notification is delayed by the specified duration. + /// Delays each element notification by the specified duration. /// The time span by which to delay each element notification. Must be non-negative. /// An optional time provider for controlling timing. If null, /// is used. diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs index e25370cc..a81a2174 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SignalAsyncExtensions.Timeout.Rx.cs @@ -12,10 +12,7 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// - /// Applies a timeout policy to the observable sequence. If the next element is not received within - /// the specified time span, the sequence completes with a . - /// + /// Completes with a TimeoutException when the next element misses the deadline. /// The maximum time span allowed between consecutive elements. Must be positive. /// An observable sequence that mirrors the source but completes with a /// if any inter-element interval exceeds the specified interval. @@ -27,10 +24,7 @@ public IObservableAsync Timeout(TimeSpan dueTime) return new TimeoutSignal(source, dueTime, TimeProvider.System); } - /// - /// Applies a timeout policy to the observable sequence. If the next element is not received within - /// the specified time span, the sequence completes with a . - /// + /// Completes with a TimeoutException when the next element misses the deadline. /// The maximum time span allowed between consecutive elements. Must be positive. /// An optional time provider for controlling timing. If null, /// is used. @@ -44,10 +38,7 @@ public IObservableAsync Timeout(TimeSpan dueTime, TimeProvider? timeProvider) return new TimeoutSignal(source, dueTime, timeProvider ?? TimeProvider.System); } - /// - /// Applies a timeout policy to the observable sequence. If the next element is not received within - /// the specified time span, the sequence switches to the specified fallback observable. - /// + /// Switches to the fallback sequence when the next element misses the deadline. /// The maximum time span allowed between consecutive elements. Must be positive. /// The fallback observable to switch to when a timeout occurs. Cannot be null. /// An observable sequence that mirrors the source, switching to the fallback sequence @@ -62,10 +53,7 @@ public IObservableAsync Timeout(TimeSpan dueTime, IObservableAsync fallbac return new TimeoutWithFallbackSignal(source, dueTime, fallback, TimeProvider.System); } - /// - /// Applies a timeout policy to the observable sequence. If the next element is not received within - /// the specified time span, the sequence switches to the specified fallback observable. - /// + /// Switches to the fallback sequence when the next element misses the deadline. /// The maximum time span allowed between consecutive elements. Must be positive. /// The fallback observable to switch to when a timeout occurs. Cannot be null. /// An optional time provider for controlling timing. If null, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs index a0ff1658..8b9dc410 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/StartWith.cs @@ -18,7 +18,6 @@ public static partial class SignalAsyncExtensions /// The value to prepend to the sequence. /// An observable sequence that emits the specified value first, followed by the elements /// of the source sequence. - /// Equivalent to Prepend(T), under the System.Reactive name. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync StartWith(T value) => new LeadSignal(source, [value]); @@ -26,8 +25,7 @@ public static partial class SignalAsyncExtensions /// The values to prepend to the sequence. Cannot be null. /// An observable sequence that emits the specified values first, followed by the elements /// of the source sequence. - /// Equivalent to Prepend(IEnumerable<T>), under the System.Reactive name; the values are - /// emitted in collection order. + /// The values are emitted in enumeration order. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync StartWith(IEnumerable values) => new LeadSignal(source, values); diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs index 252a0faf..9be20488 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SubscribeAsync.cs @@ -25,7 +25,7 @@ public static partial class SignalAsyncExtensions /// A value task that represents the asynchronous operation. The result is an /// that can be disposed to unsubscribe from the data source. /// The source sequence or is . - /// Callbacks may be invoked concurrently, so the supplied delegates must be thread-safe. + /// Callbacks are serialized per subscription; overlapping notifications from another thread are rejected. public ValueTask SubscribeAsync( Func onNextAsync, Func? onErrorResumeAsync, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs index 817c72bd..eaf77654 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SwitchToSignal.cs @@ -238,7 +238,7 @@ internal async ValueTask SubscribeReplacementInnerAsync( } } - /// Disposes the current inner subscription, the outer subscription, and optionally forwards a completion result to the downstream observer. This method is idempotent. + /// Disposes the current inner and outer subscriptions once, optionally forwarding completion. /// The completion result to forward, or if disposing without signaling completion. /// A task representing the asynchronous operation. internal async ValueTask FinishAsync(Result? result) diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest2.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest2.cs index da778461..01d8383e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest2.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatest2.cs @@ -23,11 +23,8 @@ public static partial class SignalAsyncExtensions /// Source observable 2 whose latest value is combined. /// Projects the latest value of every source into a result. /// An observable sequence of projected results. - /// - /// The returned sequence does not produce a value until every source has emitted at least - /// once. After that, each new value from any source produces a fresh projection using the - /// most recent value from each. Completion / failure of any source propagates downstream. - /// + /// Emits after both sources have a value, then on either source's updates. + /// Successful completion waits for both sources; a failure terminates immediately. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservableAsync SyncLatest( IObservableAsync src2, diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs index 3457e33c..d1f89323 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/SyncLatestEnumerable.cs @@ -21,7 +21,6 @@ public IObservableAsync> SyncLatest() { ArgumentExceptionHelper.ThrowIfNull(sources); - // An identity selector lets one subscription implementation back both shapes. return new SyncLatestEnumerableSignal>(sources, static s => s); } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs index 50e41393..df516d1e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/TakeUntil.cs @@ -389,11 +389,21 @@ internal void LinkExternalCancellation(CancellationToken external) => /// Callback invoked when the external cancellation token is canceled; forwards completion to the observer. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void CompleteFromCancellation() => FireAndForgetHelper.Run(async () => + internal void CompleteFromCancellation() => FireAndForgetHelper.Run(CompleteAfterYieldAsync); + + /// Forwards successful completion through the notification gate. + /// The completion notification. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal ValueTask CompleteFromCancellationAsync() => _lifecycle.RelayCompletionAsync(Result.Success); + + /// Defers completion until after the cancellation callback returns. + /// The deferred completion notification. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private async ValueTask CompleteAfterYieldAsync() { await Task.Yield(); - await _lifecycle.RelayCompletionAsync(Result.Success).ConfigureAwait(false); - }); + await CompleteFromCancellationAsync().ConfigureAwait(false); + } } } @@ -492,7 +502,7 @@ internal void AwaitStopThenComplete() { Volatile.Write(ref _stopRegistration, _parent._stopSignal(Stop)); - // Release registrations returned after a synchronous stop notification. + // Registrations returned after termination are disposed immediately. if (Volatile.Read(ref _stopSignalled) != 1) { return; @@ -552,7 +562,7 @@ private async ValueTask DisposeStopRegistrationAsync() } catch (Exception disposeError) { - // Best-effort: a secondary dispose failure while ending the sequence goes to the global handler. + // Secondary disposal failures reach the global handler. UnhandledExceptionHandler.ReportUnhandledException(disposeError); } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs index 3f40e1b4..3e346c54 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Throttle.cs @@ -12,13 +12,12 @@ public static partial class SignalAsyncExtensions /// The source observable sequence. extension(IObservableAsync source) { - /// Ignores elements from the source sequence that are followed by another element within the specified time span. Only the last element in each burst is forwarded. + /// Forwards the latest element after a full quiet period. /// The time span that must elapse after the last element before it is forwarded. /// Must be non-negative. /// An observable sequence containing only those elements that are not followed by another /// element within the specified due time. /// Thrown if is negative. - /// Each source element restarts the quiet period, so a continuous burst forwards only its final element. public IObservableAsync Throttle(TimeSpan dueTime) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(dueTime, TimeSpan.Zero); @@ -26,7 +25,7 @@ public IObservableAsync Throttle(TimeSpan dueTime) return new ThrottleSignal(source, dueTime, TimeProvider.System); } - /// Ignores elements from the source sequence that are followed by another element within the specified time span. Only the last element in each burst is forwarded. + /// Forwards the latest element after a full quiet period. /// The time span that must elapse after the last element before it is forwarded. /// Must be non-negative. /// An optional time provider for controlling timing. If null, @@ -34,7 +33,6 @@ public IObservableAsync Throttle(TimeSpan dueTime) /// An observable sequence containing only those elements that are not followed by another /// element within the specified due time. /// Thrown if is negative. - /// Each source element restarts the quiet period, so a continuous burst forwards only its final element. public IObservableAsync Throttle(TimeSpan dueTime, TimeProvider? timeProvider) { ArgumentOutOfRangeExceptionHelper.ThrowIfLessThan(dueTime, TimeSpan.Zero); @@ -55,10 +53,18 @@ internal ValueTask DelayAsync( TimeProvider timeProvider, CancellationToken cancellationToken) => timeProvider == TimeProvider.System - ? new(Task.Delay(delay, cancellationToken)) + ? DelayOnSystemClockAsync(delay, cancellationToken) : PooledDelaySource.Rent().BeginAsync(delay, timeProvider, cancellationToken); } + /// Waits for the system clock or cancellation. + /// The duration to wait. + /// Cancellation for the wait. + /// The delay operation. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static ValueTask DelayOnSystemClockAsync(TimeSpan delay, CancellationToken cancellationToken) => + new(Task.Delay(delay, cancellationToken)); + /// Async observable that debounces the source sequence, only forwarding elements that are not followed by another element within the specified due time. /// The type of elements in the sequence. /// The source observable sequence to throttle. @@ -128,8 +134,6 @@ internal async Task FireAfterDelayAsync(T value, long id, CancellationToken canc } catch (Exception e) { - // UnhandledExceptionHandler filters OperationCanceledException internally so - // a separate OCE-only catch would just duplicate the silent-drop behavior. UnhandledExceptionHandler.ReportUnhandledException(e); } } @@ -171,7 +175,7 @@ protected override ValueTask OnCompletedAsyncCore(Result result) return observer.OnCompletedAsync(result); } - /// Marks any in-flight delay as superseded during disposal. The dispose token threaded through by the base observer also unblocks the awaits. + /// Invalidates pending values before releasing the observer. /// A completed task. protected override ValueTask DisposeAsyncCore() { diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs index 248bcf57..cc0c2bb5 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/Unique.cs @@ -15,9 +15,7 @@ public static partial class SignalAsyncExtensions /// Returns an observable sequence that emits only distinct consecutive elements, suppressing duplicates that are equal to the previous element. /// An observable sequence that contains only the elements from the source sequence that are not equal to their /// immediate predecessor. - /// Elements are compared using the default equality comparer for the type . Only consecutive duplicate elements are suppressed; non-consecutive duplicates are not - /// affected. + /// Uses the default equality comparer. public IObservableAsync Unique() { ArgumentExceptionHelper.ThrowIfNull(source); @@ -33,8 +31,6 @@ public IObservableAsync Unique() /// An observable sequence that contains only distinct consecutive elements from the source sequence, as /// determined by the specified equality comparer. /// Thrown if is . - /// Only elements that differ from their immediate predecessor, as judged by , are emitted; non-consecutive duplicates are not suppressed. public IObservableAsync Unique(IEqualityComparer equalityComparer) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -48,9 +44,7 @@ public IObservableAsync Unique(IEqualityComparer equalityComparer) /// A function that extracts the comparison key from each element in the source sequence. /// An observable sequence that contains only the elements from the source sequence that are not consecutive /// duplicates according to the specified key. - /// The comparison of keys uses the default equality comparer for the type . Only consecutive duplicate elements are suppressed; non-consecutive duplicates are not - /// affected. + /// Compares keys with the default equality comparer. public IObservableAsync UniqueBy(Func keySelector) { ArgumentExceptionHelper.ThrowIfNull(source); @@ -66,9 +60,6 @@ public IObservableAsync UniqueBy(Func keySelector) /// An observable sequence that contains only the elements from the source sequence that are not consecutive /// duplicates according to the specified key and comparer. /// Thrown if or is null. - /// The first element in the sequence is always emitted. Subsequent elements are emitted - /// only if their key, as determined by , is not equal to the key of the - /// immediately preceding element, as determined by . public IObservableAsync UniqueBy( Func keySelector, IEqualityComparer equalityComparer) diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs index 29fd7e11..fa408ea9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/ReplayLatestSignalAsyncStateObserverLease{T}.cs @@ -39,11 +39,11 @@ private async ValueTask RemoveObserverAsync() } catch (OperationCanceledException) { - // The signal was disposed while removal was waiting to enter the gate. + // Unsubscription is harmless after signal disposal. } catch (ObjectDisposedException) { - // The gate was disposed while removal was waiting to enter it. + // Unsubscription is harmless after signal disposal. } } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs index 9777bde9..16301b09 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/Base/StatelessReplayLatestSignalAsyncStateObserverLease{T}.cs @@ -43,11 +43,11 @@ private async ValueTask RemoveObserverAndResetAsync() } catch (OperationCanceledException) { - // The signal was disposed while removal was waiting to enter the gate. + // Unsubscription is harmless after signal disposal. } catch (ObjectDisposedException) { - // The gate was disposed while removal was waiting to enter it. + // Unsubscription is harmless after signal disposal. } } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs index 015c5b24..0c901566 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Signals/ConcurrentStatelessReplayLatestSignalAsync.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Async.Signals; /// The type of the elements processed by the Signal. /// The value replayed until something is published, and restored when the last observer /// leaves. -/// Cancellation can stop some observers from receiving a notification the others do receive. +/// Completion order across observers is unspecified, and a cancelled notification does not stop the others. [System.Diagnostics.DebuggerDisplay("ConcurrentStatelessReplayLatestSignalAsync: Value = {_state.Value}, IsDisposed = {_state.IsDisposed}")] public sealed class ConcurrentStatelessReplayLatestSignalAsync(Optional startValue) : ISignalAsync { diff --git a/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs b/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs index 83b816d1..7ac20b8c 100644 --- a/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs +++ b/src/ReactiveUI.Primitives.Async.Core/UnhandledExceptionHandler.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Async; /// Provides a mechanism for registering a global handler to process unhandled exceptions that occur during application execution. -/// The process-wide default writes the exception to . Register a -/// handler to log, clean up or notify instead. Cancellation is treated as normal flow and never reaches the -/// handler. +/// The default handler writes to System.Diagnostics.Trace; cancellation exceptions are ignored. public static class UnhandledExceptionHandler { /// The currently registered handler action invoked when an unhandled exception occurs. @@ -20,8 +18,7 @@ public static class UnhandledExceptionHandler /// Registers a handler to be invoked when an unhandled exception occurs. /// The action that receives each unhandled exception. Cannot be null. - /// There is one handler per process and registration replaces it, so a library that registers here takes - /// the sink away from the host application. A handler that throws has its exception swallowed. + /// Replaces the process-wide handler; exceptions thrown by the handler are ignored. public static void Register(Action unhandledExceptionHandler) => _unhandledException = unhandledExceptionHandler; @@ -44,7 +41,7 @@ internal static void ReportUnhandledException(Exception e) } catch { - // Ignored + // Exceptions from the registered handler are ignored. } } diff --git a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs index e1e6e7f5..8a927b98 100644 --- a/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs +++ b/src/ReactiveUI.Primitives.Async.Core/WitnessAsync.cs @@ -11,9 +11,8 @@ namespace ReactiveUI.Primitives.Async; /// Base class for an asynchronous observer: handles the notification gate, cancellation linking and disposal so a derived sink only has to implement the three Core methods. /// The type of the elements received by the observer. -/// One notification runs at a time. A reentrant call from the thread that holds the gate is allowed, but an -/// overlapping call from another thread is a contract violation: the notification is dropped and a -/// goes to . +/// Reentrant calls on the active thread are allowed; overlapping calls from another thread are dropped and +/// reported to UnhandledExceptionHandler as . [System.Diagnostics.DebuggerDisplay("WitnessAsync: Disposed = {_disposed}, CallState = {_callState}")] public abstract class WitnessAsync : IObserverAsync, IReentrantAsyncDisposable { @@ -51,10 +50,7 @@ protected WitnessAsync() /// The external token whose cancellation should trigger this observer's disposal. protected WitnessAsync(CancellationToken externalLink) => LinkExternalCancellation(externalLink); - /// - /// Gets the cancellation token that fires when this observer disposes, for an operator to link into a downstream - /// observer's dispose chain. Reading it creates the backing . - /// + /// Gets the lazily created token cancelled when this observer is disposed. public CancellationToken InternalDisposedToken => GetOrCreateDisposeCts().Token; /// Gets a value indicating whether this observer has been disposed. @@ -366,7 +362,7 @@ internal async ValueTask RouteObserverErrorAsync(Exception error, CancellationTo } } - /// Handles the sequence's terminal result. Called at most once, before this observer is disposed. + /// Handles the terminal result at most once, before disposal. /// The result of the operation to be processed during completion. /// A task that completes when the result has been handled. protected abstract ValueTask OnCompletedAsyncCore(Result result); @@ -376,13 +372,11 @@ internal async ValueTask RouteObserverErrorAsync(Exception error, CancellationTo [DebuggerStepThrough] protected void LinkExternalCancellation(CancellationToken external) { - // A token that cannot fire adds nothing to the dispose chain, so skip creating the source entirely. if (!external.CanBeCanceled) { return; } - // A cancelled token needs the source created only to hold the cancelled state for later token requests. if (external.IsCancellationRequested) { Volatile.Write(ref _disposed, 1); @@ -413,7 +407,6 @@ protected void LinkExternalCancellation(CancellationToken external) [DebuggerStepThrough] protected virtual async ValueTask DisposeAsyncCore() { - // First disposer wins; the latch moves whether or not a dispose source exists. if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; @@ -429,7 +422,7 @@ protected virtual async ValueTask DisposeAsyncCore() allOnSomethingCallsCompleted = PublishCallCompletionWaiter(); } - // Only an existing source needs the cancellation broadcast; a later token request builds one pre-cancelled. + // Tokens requested after disposal are already cancelled. var cts = Volatile.Read(ref _disposeCts); if (cts is not null) { @@ -490,7 +483,7 @@ private async ValueTask OnNextAsyncSlow(ValueTask core, LinkedTokenScope scope) } catch (OperationCanceledException) { - // Cooperative cancellation; swallow. + // Cancellation does not report a handler failure. } catch (Exception e) { diff --git a/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs b/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs index 0288932d..0eff29ad 100644 --- a/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs +++ b/src/ReactiveUI.Primitives.Avalonia.Reactive/Concurrency/AvaloniaScheduler.cs @@ -3,14 +3,13 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Disposables; +using System.Runtime.CompilerServices; using Avalonia.Threading; namespace ReactiveUI.Primitives.Reactive.Concurrency; /// Avalonia UI-thread scheduler that coalesces scheduled work onto a dispatcher drain. -/// Work runs on the dispatcher's thread at and delayed work fires on a -/// , so disposing the returned subscription stops that timer as well as suppressing work -/// that has not started. +/// Callbacks run on the dispatcher thread at Priority; cancellation stops pending timers and suppresses unstarted actions. /// [System.Diagnostics.DebuggerDisplay("AvaloniaScheduler: Dispatcher = {Dispatcher}, Priority = {Priority}")] public sealed class AvaloniaScheduler : CoalescingDispatchScheduler @@ -19,6 +18,12 @@ public sealed class AvaloniaScheduler : CoalescingDispatchScheduler public static readonly AvaloniaScheduler Instance = new(Dispatcher.UIThread, DispatcherPriority.Background); + /// Posts immediate work. + private readonly Action _post; + + /// Schedules delayed work. + private readonly Func _scheduleDelayed; + /// Initializes a new instance of the class. /// The dispatcher used to marshal work to the UI thread. /// is . @@ -32,9 +37,26 @@ public AvaloniaScheduler(Dispatcher dispatcher) /// Dispatcher priority used for posted drains and delayed work. /// is . public AvaloniaScheduler(Dispatcher dispatcher, DispatcherPriority priority) + : this(dispatcher, priority, null, null) + { + } + + /// Initializes a new instance of the class. + /// The dispatcher exposed by the scheduler. + /// The selected dispatcher priority. + /// Posts drains, or null to use the dispatcher. + /// Schedules delayed work, or null to use dispatcher timers. + /// is null. + internal AvaloniaScheduler( + Dispatcher dispatcher, + DispatcherPriority priority, + Action? post, + Func? scheduleDelayed) { Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); Priority = priority; + _post = post ?? PostToDispatcher; + _scheduleDelayed = scheduleDelayed ?? StartDispatcherTimer; } /// Gets the dispatcher used to marshal work to the UI thread. @@ -46,13 +68,21 @@ public AvaloniaScheduler(Dispatcher dispatcher, DispatcherPriority priority) /// protected override bool Post(Action drain) { - Dispatcher.Post(drain, Priority); + _post(drain); return true; } /// [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) + protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) => + _scheduleDelayed(work, dueTime); + + /// Starts a cancellable dispatcher timer. + /// The callback to run when due. + /// The requested delay. + /// The timer cancellation handle. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private IDisposable StartDispatcherTimer(Action work, TimeSpan dueTime) { DispatcherTimer timer = new(Priority, Dispatcher) { Interval = dueTime }; timer.Tick += (_, _) => @@ -63,4 +93,10 @@ protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTim timer.Start(); return Disposable.Create(timer, static value => value.Stop()); } + + /// Posts the callback at the configured dispatcher priority. + /// The callback to post. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PostToDispatcher(Action drain) => Dispatcher.Post(drain, Priority); } diff --git a/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs b/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs index 4a556500..3e8a7440 100644 --- a/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs +++ b/src/ReactiveUI.Primitives.Avalonia/Concurrency/AvaloniaScheduler.cs @@ -9,9 +9,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// Avalonia UI-thread scheduler that coalesces scheduled work onto a dispatcher drain. -/// Work runs on the dispatcher's thread at , one batch per posted drain; scheduling from -/// that thread queues the item for the next drain rather than running it inline. Delayed work fires on a -/// , and an item cancelled before its drain reaches it is skipped. +/// Callbacks run at Priority in posted dispatcher batches without inline reentrancy; cancellation suppresses unstarted work. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class AvaloniaScheduler : ISequencer @@ -36,10 +34,25 @@ public AvaloniaScheduler(Dispatcher dispatcher) /// Dispatcher priority used for posted drains and delayed work. /// is . public AvaloniaScheduler(Dispatcher dispatcher, DispatcherPriority priority) + : this(dispatcher, priority, null, null) + { + } + + /// Initializes a new instance of the class. + /// The dispatcher exposed by the scheduler. + /// The selected dispatcher priority. + /// Posts drains, or null to use the dispatcher. + /// Schedules delayed work, or null to use dispatcher timers. + /// is null. + internal AvaloniaScheduler( + Dispatcher dispatcher, + DispatcherPriority priority, + Func? post, + Action? scheduleDelayed) { Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); Priority = priority; - _state = new(this, Post, RunDrain, ScheduleDelayed); + _state = new(this, post ?? Post, RunDrain, scheduleDelayed ?? ScheduleDelayed); } /// Gets the dispatcher used to marshal work to the UI thread. @@ -70,6 +83,7 @@ public AvaloniaScheduler(Dispatcher dispatcher, DispatcherPriority priority) /// Marshals the cached drain callback onto the dispatcher. /// The drain callback. /// , since the dispatcher accepts the work. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private bool Post(Action drain) { Dispatcher.Post(drain, Priority); diff --git a/src/ReactiveUI.Primitives.Avalonia/ReactiveUI.Primitives.Avalonia.csproj b/src/ReactiveUI.Primitives.Avalonia/ReactiveUI.Primitives.Avalonia.csproj index 5b576213..8fa11a07 100644 --- a/src/ReactiveUI.Primitives.Avalonia/ReactiveUI.Primitives.Avalonia.csproj +++ b/src/ReactiveUI.Primitives.Avalonia/ReactiveUI.Primitives.Avalonia.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs b/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs index 97069fa7..894eb08c 100644 --- a/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs +++ b/src/ReactiveUI.Primitives.Blazor.Reactive/Concurrency/BlazorRendererSequencer.cs @@ -9,9 +9,7 @@ namespace ReactiveUI.Primitives.Blazor.Reactive.Concurrency; /// Scheduler that coalesces scheduled work through a Blazor renderer dispatcher delegate. -/// Work runs on the renderer's dispatcher, and delayed work waits on a background scheduler that marshals it -/// back through the renderer when due. A renderer task that faults reaches -/// . +/// Immediate and delayed callbacks run on the renderer dispatcher; renderer task failures reach UnhandledExceptionHandler. /// [System.Diagnostics.DebuggerDisplay("BlazorRendererSequencer: InvokeAsync = {_invokeAsync}, UnhandledExceptionHandler = {UnhandledExceptionHandler}")] public sealed class BlazorRendererSequencer : CoalescingDispatchScheduler @@ -33,40 +31,38 @@ public BlazorRendererSequencer(Dispatcher dispatcher) { } - /// - /// Gets or sets the handler for exceptions the renderer task surfaces after the drain is posted. - /// When , faults are rethrown on the thread pool instead of being lost as - /// unobserved task exceptions. - /// + /// Gets or sets the renderer fault handler, rethrowing faults on the thread pool when null. public Action? UnhandledExceptionHandler { get; set; } - /// - protected override bool Post(Action drain) + /// Registers fault observation unless the renderer task has already succeeded. + /// The renderer task to observe. + /// Registers the task and its owning sequencer for fault notification. + internal void ObserveFaults(Task task, Action register) { - ObserveFaults(_invokeAsync(drain)); - return true; + if (task.IsCompletedSuccessfully) + { + return; + } + + register(task, this); } - /// Routes renderer-task faults to the handler instead of leaving them unobserved. - /// The renderer task to observe. - private void ObserveFaults(Task task) + /// Forwards a renderer task's base exception when the task faulted. + /// The renderer task whose state changed. + internal void CompleteRendererTask(Task task) { - if (task.IsCompletedSuccessfully) + if (!task.IsFaulted) { return; } - _ = task.ContinueWith( - static (t, state) => ((BlazorRendererSequencer)state!).HandleFault(t.Exception!.GetBaseException()), - this, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + HandleFault(task.Exception!.GetBaseException(), Rethrow); } /// Hands a fault to the handler, or rethrows it on the thread pool. /// The observed fault. - private void HandleFault(Exception exception) + /// The fallback invoked when no fault handler is configured. + internal void HandleFault(Exception exception, Action rethrow) { var handler = UnhandledExceptionHandler; if (handler is not null) @@ -75,8 +71,33 @@ private void HandleFault(Exception exception) return; } + rethrow(exception); + } + + /// + protected override bool Post(Action drain) + { + ObserveFaults(_invokeAsync(drain), RegisterFaultContinuation); + return true; + } + + /// Registers notification for a renderer task that faults. + /// The renderer task to observe. + /// The owner receiving the fault notification. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void RegisterFaultContinuation(Task task, BlazorRendererSequencer sequencer) => + _ = task.ContinueWith( + static (completed, state) => ((BlazorRendererSequencer)state!).CompleteRendererTask(completed), + sequencer, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + /// Rethrows a fault on the thread pool with its captured stack. + /// The fault to rethrow. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void Rethrow(Exception exception) => _ = ThreadPool.UnsafeQueueUserWorkItem( static state => ((ExceptionDispatchInfo)state!).Throw(), ExceptionDispatchInfo.Capture(exception)); - } } diff --git a/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs b/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs index 0f6434bb..3fa09091 100644 --- a/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs +++ b/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs @@ -11,10 +11,7 @@ namespace ReactiveUI.Primitives.Blazor.Concurrency; /// Sequencer that coalesces scheduled work through a Blazor renderer dispatcher delegate. -/// Work runs on the renderer's dispatcher, one batch per posted drain; scheduling from that thread queues the -/// item for the next drain rather than running it inline. Delayed work waits on a shared timer that marshals it back -/// through the renderer when due, and a renderer task that faults reaches -/// . +/// Callbacks run in posted renderer batches without inline reentrancy; renderer task failures reach UnhandledExceptionHandler. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class BlazorRendererSequencer : ISequencer @@ -42,11 +39,7 @@ public BlazorRendererSequencer(Dispatcher dispatcher) { } - /// - /// Gets or sets the handler for exceptions the renderer task surfaces after the drain is posted. - /// When , faults are rethrown on the thread pool instead of being lost as - /// unobserved task exceptions. - /// + /// Gets or sets the renderer fault handler, rethrowing faults on the thread pool when null. public Action? UnhandledExceptionHandler { get; set; } /// @@ -67,35 +60,35 @@ public BlazorRendererSequencer(Dispatcher dispatcher) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp); - /// Marshals the cached drain callback through the Blazor renderer. - /// The drain callback. - /// , since the renderer always accepts the work. - private bool Post(Action drain) + /// Registers fault observation unless the renderer task has already succeeded. + /// The renderer task to observe. + /// Registers the task and its owning sequencer for fault notification. + internal void ObserveFaults(Task task, Action register) { - ObserveFaults(_invokeAsync(drain)); - return true; + if (task.IsCompletedSuccessfully) + { + return; + } + + register(task, this); } - /// Routes renderer-task faults to the handler instead of leaving them unobserved. - /// The renderer task to observe. - private void ObserveFaults(Task task) + /// Forwards a renderer task's base exception when the task faulted. + /// The renderer task whose state changed. + internal void CompleteRendererTask(Task task) { - if (task.IsCompletedSuccessfully) + if (!task.IsFaulted) { return; } - _ = task.ContinueWith( - static (t, state) => ((BlazorRendererSequencer)state!).HandleFault(t.Exception!.GetBaseException()), - this, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + HandleFault(task.Exception!.GetBaseException(), Rethrow); } /// Hands a fault to the handler, or rethrows it on the thread pool. /// The observed fault. - private void HandleFault(Exception exception) + /// The fallback invoked when no fault handler is configured. + internal void HandleFault(Exception exception, Action rethrow) { var handler = UnhandledExceptionHandler; if (handler is not null) @@ -104,9 +97,36 @@ private void HandleFault(Exception exception) return; } + rethrow(exception); + } + + /// Registers notification for a renderer task that faults. + /// The renderer task to observe. + /// The owner receiving the fault notification. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void RegisterFaultContinuation(Task task, BlazorRendererSequencer sequencer) => + _ = task.ContinueWith( + static (completed, state) => ((BlazorRendererSequencer)state!).CompleteRendererTask(completed), + sequencer, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + /// Rethrows a fault on the thread pool with its captured stack. + /// The fault to rethrow. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void Rethrow(Exception exception) => _ = ThreadPool.UnsafeQueueUserWorkItem( static state => ((ExceptionDispatchInfo)state!).Throw(), ExceptionDispatchInfo.Capture(exception)); + + /// Marshals the cached drain callback through the Blazor renderer. + /// The drain callback. + /// , since the renderer always accepts the work. + private bool Post(Action drain) + { + ObserveFaults(_invokeAsync(drain), RegisterFaultContinuation); + return true; } /// Runs one queued batch on the coalescing engine. diff --git a/src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj b/src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj index e14ce0a3..4a74da99 100644 --- a/src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj +++ b/src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs index ff33b05b..5a0ff809 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AggregateWitness.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// Single-source sink that folds every observed value through an immutable value-type -/// and emits the aggregate result once the source completes. The accumulator is advanced functionally: each value -/// yields a replacement accumulator rather than mutating the current one. -/// +/// Folds source values through an immutable accumulator and emits the result when the source completes. /// The observed value type. /// The terminal result type. /// The value-type accumulator that folds values and yields the result. diff --git a/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs index d614bd16..ebee5ffe 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/AsyncEnumerableSignal{T}.cs @@ -98,7 +98,7 @@ internal async Task PumpAsync() Volatile.Write(ref _enumerator, enumerator); while (!_cts.IsCancellationRequested && await enumerator.MoveNextAsync().ConfigureAwait(false)) { - // Cancellation during the move suppresses its buffered value. + // Cancellation discards the current buffered value. if (_cts.IsCancellationRequested) { break; @@ -114,7 +114,7 @@ internal async Task PumpAsync() } catch (OperationCanceledException) when (_cts.IsCancellationRequested) { - // Disposal requested cancellation; observers should not receive a terminal signal. + // Subscription disposal does not send a terminal notification. } catch (Exception error) when (!_cts.IsCancellationRequested) { @@ -135,7 +135,7 @@ internal async Task PumpAsync() /// The enumerator to dispose. private static void FireAndForgetDispose(IAsyncEnumerator enumerator) { - // IDisposable cannot await enumerator disposal. + // Disposal returns before asynchronous enumerator cleanup finishes. _ = ObserveAsync(enumerator); static async Task ObserveAsync(IAsyncEnumerator enumerator) @@ -146,7 +146,7 @@ static async Task ObserveAsync(IAsyncEnumerator enumerator) } catch (NotSupportedException) { - // Some enumerators only support disposal from the enumeration path. + // Unsupported concurrent enumerator disposal is ignored. } } } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs index 3d332734..2b05786c 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs @@ -51,7 +51,6 @@ public void OnNext(T value) _buffer = buffer; } - // Take while not skipping; the window index doubles as the array slot. if (idx >= 0) { buffer![idx] = value; @@ -64,11 +63,9 @@ public void OnNext(T value) return; } - // Reset the index before a throwing observer can release the buffer. _buffer = null; _index = 0 - _skip; - // A full window is exactly the right size, so it needs no trimming. Emit(buffer!); } @@ -119,7 +116,7 @@ public void OnCompleted() /// public void Dispose() { - // Reject further values on every teardown path, including observer failure. + // Values after teardown are ignored, including after observer failure. Volatile.Write(ref _done, 1); SinkSubscription.Dispose(ref _subscription); } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs b/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs index a7f1fd99..cd14afc8 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/IAggregator.cs @@ -4,12 +4,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// An immutable value-type accumulator for a single-source aggregate sink: each folds a value -/// into the running state and returns the next accumulator, while yields the terminal value. -/// Implement it as a so -/// can fold without allocating per value. -/// +/// Accumulates values by returning a replacement state from Add and exposing the aggregate through Result. /// The observed value type. /// The terminal result type. /// The implementing accumulator type, returned by . diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs b/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs index d4448eb4..fefd5803 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SinkSubscription.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// Assign-once, dispose-once management of a sink's single upstream subscription. Each helper takes the -/// caller-owned field by , so a sink can implement -/// directly without deriving from a shared base class. -/// +/// Assigns and disposes a single upstream subscription held in a caller-owned field. public static class SinkSubscription { /// Sentinel stored once a sink is disposed so any late subscription is torn down immediately. diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs b/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs index 160d0185..6be85733 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SinkTerminal.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// Terminal-forwarding helpers for single-source sink observers: deliver one terminal notification -/// downstream and dispose the sink afterwards, optionally latching a caller-owned done flag so only -/// the first terminal call reaches the observer. -/// +/// Forwards a terminal notification and disposes the sink, optionally preventing repeated terminal delivery. public static class SinkTerminal { /// Forwards a fault to and then disposes . diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs b/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs index ba06a5df..b5c0b077 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SubscriptionSlots.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// Interlocked single-assignment subscription slots for a sink that resubscribes: a slot holds at most one live -/// subscription and, once released, swaps to a sentinel so a late assignment is disposed instead of stored. -/// +/// Holds one subscription per slot and disposes assignments made after a slot is released. public static class SubscriptionSlots { /// The sentinel stored in a slot once it has been released. diff --git a/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs index 424c7a90..08463359 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/SynchronizeWitness.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// Observer that serializes notifications behind a gate, so downstream operators observe the single-threaded -/// OnNext* then OnError|OnCompleted grammar even when the upstream source delivers -/// concurrently. Place one ahead of a stateful sink to consume a source that does not honour that grammar. -/// +/// Serializes concurrent observer notifications so values precede a single terminal notification. /// The value type. [System.Diagnostics.DebuggerDisplay("SynchronizeWitness: Observer = {_observer}, Subscription = {_subscription}")] public sealed class SynchronizeWitness : IObserver, IDisposable diff --git a/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs index f0b69257..8eba8a96 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs @@ -7,14 +7,12 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// Signal that creates a resource for each subscription and disposes it once the inner signal terminates or -/// the subscription is disposed. A fault from either factory is delivered to the observer as an error. -/// +/// Creates a resource per subscription and disposes it when the sequence terminates or the subscription is disposed. /// The resource type, disposed with the subscription. /// The value type. /// Creates the resource, once per subscription. /// Creates the signal that consumes the resource. +/// A failure from either factory is delivered to the observer. [System.Diagnostics.DebuggerDisplay("UseSignal: ResourceFactory = {_resourceFactory}, SignalFactory = {_signalFactory}")] public sealed class UseSignal( Func resourceFactory, diff --git a/src/ReactiveUI.Primitives.Core/Advanced/WitnessTeardown.cs b/src/ReactiveUI.Primitives.Core/Advanced/WitnessTeardown.cs index bec05d2d..23771670 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/WitnessTeardown.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/WitnessTeardown.cs @@ -13,7 +13,7 @@ public static class WitnessTeardown /// on the first disposal; otherwise . public static bool Dispose(ref int disposed, ref IDisposable? cancel) { - // Atomic run-once latch so concurrent disposal cannot double-tear-down. + // Concurrent disposal runs teardown at most once. if (Interlocked.Exchange(ref disposed, 1) != 0) { return false; diff --git a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs index b1c697c4..778a87b4 100644 --- a/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs +++ b/src/ReactiveUI.Primitives.Core/Concurrency/ScheduledItem.cs @@ -127,10 +127,7 @@ public ScheduledItem( /// the same due-time ordering, or a value greater than zero when this item is due later. Any non-null item compares /// greater than . /// - /// - /// Ordering is by due time and says nothing about equality: and the equality - /// operators compare reference identity instead. - /// + /// Ordering compares due times; equality compares reference identity. public int CompareTo(ScheduledItem? other) => other is null ? 1 : _comparer.Compare(DueTime, other.DueTime); @@ -182,10 +179,7 @@ public void Dispose() public override int GetHashCode() => RuntimeHelpers.GetHashCode(this); /// Runs this scheduled work item if it has not been canceled. - /// - /// The callback runs once and the disposable it returns is stored. Cancellation before, during, or immediately - /// after the call disposes that disposable, so cancellation reaches work the callback created. - /// + /// Cancellation disposes the resource returned by the callback, including when cancellation overlaps its execution. public void Invoke() { if (IsDisposed) @@ -201,11 +195,17 @@ public void Invoke() return; } + ReleaseCanceledResult(); + } + + /// Releases the published result if the work item is cancelled. + internal void ReleaseCanceledResult() + { if (!IsDisposed) { return; } - disposable.Dispose(); + Interlocked.Exchange(ref _disposable, EmptyDisposable.Instance)?.Dispose(); } } diff --git a/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs b/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs index 2dca23fb..16419e4a 100644 --- a/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/ConnectableSignal{T}.cs @@ -22,7 +22,7 @@ public sealed class ConnectableSignal : IObservable /// Multicast hub that receives source values. private readonly ISignal _hub; - /// Active source connection slot. The returned connection handle owns disposal. + /// The active source connection, whose returned handle owns disposal. private StrongBox? _connection; /// Set after the source sends a terminal notification to the hub. @@ -54,7 +54,6 @@ public IDisposable Connect() return Scope.Empty; } - // A dedicated disposable type for the handle, so connecting captures no closure. if (_connection?.Value is { } activeConnection) { return activeConnection; diff --git a/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs b/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs index 2a89f996..4c8018af 100644 --- a/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Core/TimeInterval{T}.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Core; -/// -/// Represents a value paired with a time interval, whose meaning is the producer's: how long the value took to -/// produce, the gap since the previous value, or its delivery time relative to a base. -/// +/// Pairs a value with a producer-defined time interval. /// The annotated value type. [Serializable] [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Primitives.Core/Optional.cs b/src/ReactiveUI.Primitives.Core/Optional.cs index 201f27c5..0f47aa5d 100644 --- a/src/ReactiveUI.Primitives.Core/Optional.cs +++ b/src/ReactiveUI.Primitives.Core/Optional.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives; -/// -/// Represents an optional value, letting an API distinguish an absent value from a default one without using -/// . A value is treated as absent. -/// +/// Represents a value that may be absent, treating null as absent. /// The type of the value that may be contained by the optional. [System.Diagnostics.DebuggerDisplay("Optional: HasValue = {HasValue}, Value = {_value}")] public readonly record struct Optional diff --git a/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs b/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs index b412ba6a..1b40547b 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/AsyncSignal.cs @@ -265,7 +265,6 @@ internal void WaitIfPending(Action> wait) [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private static void WaitForCompletion(AsyncSignal signal) { - // Registration precedes waiting so completion cannot be missed. using ManualResetEvent completionEvent = new(false); signal.SubscribeCompletion(() => completionEvent.Set(), false); _ = completionEvent.WaitOne(); diff --git a/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs b/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs index 89f66d2a..eb3b5bdc 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/AwaitWitness.cs @@ -49,12 +49,20 @@ public void OnNext(T value) { } + /// Posts a continuation to the captured context. + /// The context receiving the callback. + /// The callback to post. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Post(SynchronizationContext context, Action callback) => + context.Post(static state => ((Action?)state)?.Invoke(), callback); + /// Posts the continuation to its captured context, or invokes it directly when none was captured. private void InvokeOnOriginalContext() { if (_context is not null) { - _context.Post(static state => ((Action?)state)?.Invoke(), _callback); + Post(_context, _callback); } else { diff --git a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs index 16da1d35..9de914ed 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/BehaviorSignalState{T}.cs @@ -150,7 +150,7 @@ internal IDisposable Subscribe(IWitnessRemovable owner, IObserver observer ThrowIfDisposed(); if (!_isStopped) { - // Serialize initial delivery with live broadcast to preserve order and prevent duplicates. + // Initial and live values are delivered in order without duplicates. _broadcaster.Add(observer); var subscription = new BehaviorWitnessHandler(owner, observer); observer.OnNext(_lastValue!); diff --git a/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs index cf8679b2..6eacc452 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/Broadcaster{T}.cs @@ -37,62 +37,24 @@ public struct Broadcaster : IEquatable> /// when the broadcasters reference different observer sets; otherwise, . public static bool operator !=(Broadcaster left, Broadcaster right) => !left.Equals(right); - /// Adds an observer with a lock-free compare-and-swap, so no external lock is required. + /// Adds an observer safely during concurrent updates. /// Observer to add. public void Add(IObserver observer) { - while (true) - { - var current = Volatile.Read(ref _observers); - object next; - if (current is IObserver[] many) - { - var copy = new IObserver[many.Length + 1]; - Array.Copy(many, copy, many.Length); - copy[many.Length] = observer; - next = copy; - } - else if (current is IObserver single) - { - next = new[] { single, observer }; - } - else if (Interlocked.CompareExchange(ref _observers, observer, null) is null) - { - return; - } - else - { - continue; - } - - if (ReferenceEquals(Interlocked.CompareExchange(ref _observers, next, current), current)) - { - return; - } - } + var current = Volatile.Read(ref _observers); + AddWithRetry(current, observer); } /// Removes all observers from the broadcaster. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Clear() => Volatile.Write(ref _observers, null); - /// Removes an observer from the broadcaster using a lock-free compare-and-swap. + /// Removes an observer safely during concurrent updates. /// Observer to remove. public void Remove(IObserver observer) { - while (true) - { - var current = Volatile.Read(ref _observers); - if (!TryComputeRemoval(current, observer, out var next)) - { - return; - } - - if (ReferenceEquals(Interlocked.CompareExchange(ref _observers, next, current), current)) - { - return; - } - } + var current = Volatile.Read(ref _observers); + RemoveWithRetry(current, observer); } /// Broadcasts a value to the current observers. @@ -197,6 +159,42 @@ public override readonly int GetHashCode() return hash; } + /// Adds an observer if the observed slot has not changed. + /// The live observer slot. + /// The observed slot value. + /// The observer to add. + /// True when the observer was added; false when the observed slot was stale. + internal static bool TryAdd(ref object? observers, object? current, IObserver observer) + { + object next; + if (current is IObserver[] many) + { + var copy = new IObserver[many.Length + 1]; + Array.Copy(many, copy, many.Length); + copy[many.Length] = observer; + next = copy; + } + else if (current is IObserver single) + { + next = new[] { single, observer }; + } + else + { + return Interlocked.CompareExchange(ref observers, observer, null) is null; + } + + return ReferenceEquals(Interlocked.CompareExchange(ref observers, next, current), current); + } + + /// Removes an observer if the observed slot has not changed. + /// The live observer slot. + /// The observed slot value. + /// The observer to remove. + /// True when removal completed or the observer was absent; false when the observed slot was stale. + internal static bool TryRemove(ref object? observers, object? current, IObserver observer) => + !TryComputeRemoval(current, observer, out var next) + || ReferenceEquals(Interlocked.CompareExchange(ref observers, next, current), current); + /// Computes the observer-set value that results from removing an observer. /// The current observer-set snapshot. /// The observer to remove. @@ -233,4 +231,38 @@ private static bool TryComputeRemoval(object? current, IObserver observer, ou next = copy; return true; } + + /// Retries observer addition until the observed slot is current. + /// The initial observer snapshot. + /// The observer to add. + [ExcludeFromCodeCoverage] + private void AddWithRetry(object? current, IObserver observer) + { + while (true) + { + if (TryAdd(ref _observers, current, observer)) + { + return; + } + + current = Volatile.Read(ref _observers); + } + } + + /// Retries observer removal until the observed slot is current. + /// The initial observer snapshot. + /// The observer to remove. + [ExcludeFromCodeCoverage] + private void RemoveWithRetry(object? current, IObserver observer) + { + while (true) + { + if (TryRemove(ref _observers, current, observer)) + { + return; + } + + current = Volatile.Read(ref _observers); + } + } } diff --git a/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs index 41fd43ed..5f359835 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/CommandSignal{TResult}.cs @@ -341,7 +341,6 @@ private void BeginExecution() /// The running state. private void SetRunning(bool value) { - // Publish the flag and stream value under the installation gate. lock (_runningGate) { _isRunning = value; @@ -365,7 +364,7 @@ private void ReconcileRunningState() } } - /// Pushes the authoritative running flag onto the stream when one is installed. Caller holds the gate. + /// Publishes the current running state, when observed, while the caller holds the gate. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void PublishRunningState() => Volatile.Read(ref _isRunningState)?.OnNext(_isRunning); @@ -413,7 +412,6 @@ private void ThrowIfDisposed() [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void AddResult(IObserver observer) { - // Retry only when another thread replaced the observed snapshot. while (true) { if (TryAddResult(ref _resultObservers, Volatile.Read(ref _resultObservers), observer)) @@ -432,7 +430,6 @@ private void AddResult(IObserver observer) [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private void RemoveResult(IObserver observer) { - // Retry only when another thread replaced the observed snapshot. while (true) { if (TryRemoveResult(ref _resultObservers, Volatile.Read(ref _resultObservers), observer)) diff --git a/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs index 602e60a7..8b5bc8f4 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/DelayableNotificationSignal{T}.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Signals; -/// -/// A signal that passes notifications through immediately while they are not delayed, but buffers them while delayed -/// and emits a de-duplicated batch when is called (typically as the delay window opens or closes). -/// Equivalent to the Buffer(boundary).SelectMany(distinct).Publish().RefCount() pipeline in a single sink. -/// +/// Forwards notifications immediately unless delayed, then buffers them until Flush emits a batch with duplicates removed. /// The notification type. [System.Diagnostics.DebuggerDisplay("DelayableNotificationSignal: Stopped = {_stopped}, Buffer = {_buffer}")] public sealed class DelayableNotificationSignal : ISignal diff --git a/src/ReactiveUI.Primitives.Core/Signals/DelegateWitness{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/DelegateWitness{T}.cs index 71ed4217..5800110c 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/DelegateWitness{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/DelegateWitness{T}.cs @@ -6,12 +6,7 @@ namespace ReactiveUI.Primitives.Signals; -/// -/// A lightweight that forwards each notification to the supplied delegates. -/// Use it to subscribe to an without allocating a bespoke observer class. -/// The and delegates are optional; when omitted -/// the corresponding terminal notification is ignored. -/// +/// Forwards notifications to supplied delegates and ignores terminal notifications whose delegate is omitted. /// The type of the value being observed. /// The delegate invoked with each value pushed to . /// The optional delegate invoked with the exception when the sequence faults via . diff --git a/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs b/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs index 41468767..92c875f3 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/MapWithSignal{TSource,TState,TResult}.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Signals; -/// -/// Projects each source value into a new form using a caller-supplied state value, without allocating a per-value -/// closure: the state is stored on the sink and passed to the selector for each element. -/// +/// Projects each source value using the supplied state and selector. /// The source value type. /// The state type passed to the selector. /// The result value type. diff --git a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs index 073f3bcf..1bfcf994 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs @@ -127,7 +127,7 @@ public void OnError(Exception error) /// Emits a value to the current observers; a stopped signal drops it and a disposed signal throws . /// The value to emit. - /// Dispatch reads the published observer set without taking the lock, so concurrent calls are not serialized against each other. + /// Concurrent notification calls are not serialized. public void OnNext(T value) { var observers = Volatile.Read(ref _observers); @@ -247,7 +247,6 @@ protected virtual void Dispose(bool disposing) { _exception = null; - // The release write publishes the disposed flag with the terminal marker. _isDisposed = true; observers = ClearObserversLocked(DisposedMarker); } @@ -509,7 +508,6 @@ private void ClearSlotLocked(SignalSubscription?[] slots, int index) return; } - // Keep the array for the next subscriber; only the shape published to dispatch goes back to empty. _subscriptionTail = 0; Volatile.Write(ref _observers, null); } diff --git a/src/ReactiveUI.Primitives.Core/Signals/StateSignal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/StateSignal{T}.cs index 55555f5d..d6d0af08 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/StateSignal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/StateSignal{T}.cs @@ -21,7 +21,7 @@ public sealed class StateSignal : ISignal, IWitnessRemovable /// Gets the observable stream of current and subsequent values. public IObservable Changed => this; - /// Gets or sets the current value. Setting the value notifies observers even when equal to the previous value. + /// Gets or sets the current value, notifying observers on every assignment even when the value is unchanged. public T Value { get => _state.GetValue(); diff --git a/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs b/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs index 9156e8b7..533d8086 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/TapWithSignal{T,TState}.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Signals; -/// -/// Invokes a stateful action for each source value while forwarding the value unchanged, without allocating a -/// per-value closure: the state is stored on the sink and passed to the action for each element. -/// +/// Invokes the supplied action with each source value and state, then forwards the value unchanged. /// The value type. /// The state type passed to the action. /// The source sequence. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs b/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs index a01e09c2..3e18102f 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/ConcurrencyLimiter.cs @@ -7,15 +7,14 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Drains a task sequence with at most tasks in flight, emitting each result as it -/// completes and completing once the sequence is exhausted. A faulted or cancelled task terminates the sequence with -/// that task's exception. Subscribers share one enumerator over , so a second -/// subscription continues draining where the first stopped, and disposing any subscription halts the drain for all. -/// +/// Runs task factories with bounded concurrency and emits results in completion order. /// The type of the task results. /// The task functions to drain. /// The maximum concurrency. +/// +/// Subscribers share progress; disposing any subscription stops every drain. A task fault or cancellation terminates the sequence with its +/// error. +/// [System.Diagnostics.DebuggerDisplay("ConcurrencyLimiter: Outstanding = {_outstanding}, Disposed = {_disposed}")] public sealed class ConcurrencyLimiter(IEnumerable> taskFunctions, int maxConcurrency) : IObservable { @@ -115,6 +114,22 @@ internal void ProcessTaskCompletion(Subscription subscription, Task completed } } + /// Registers result delivery for the pending task. + /// The pending task. + /// The result recipient. + [ExcludeFromCodeCoverage] + private static void RegisterCompletion(Task task, Subscription subscription) => + _ = task.ContinueWith( + static (completed, state) => + { + var owner = (Subscription)state!; + owner.Limiter.ProcessTaskCompletion(owner, completed); + }, + subscription, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + /// Pulls the next task and schedules its continuation against this limiter. /// The owning subscription. private void PullNextTask(Subscription subscription) @@ -144,16 +159,10 @@ private void PullNextTask(Subscription subscription) _outstanding++; - _rator.Current?.ContinueWith( - static (ant, state) => - { - var sub = (Subscription)state!; - sub.Limiter.ProcessTaskCompletion(sub, ant); - }, - subscription, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + if (_rator.Current is { } task) + { + RegisterCompletion(task, subscription); + } } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs b/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs index 2296206c..f5213d2a 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Continuation.cs @@ -2,20 +2,20 @@ // 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.Runtime.CompilerServices; - namespace ReactiveUI.Primitives.Extensions; -/// -/// Pairs an emitted item with a release handle so a producer can wait on its consumer: hands the -/// item and this instance to an observer and returns a task that completes once that handle is disposed. The barrier -/// behind it takes two participants and is torn down by , so an instance gates one handoff. -/// +/// Pairs an emitted item with a release handle and completes the producer's task when the handle is disposed. [System.Diagnostics.DebuggerDisplay("Continuation: Locked = {_locked}, CompletedPhases = {CompletedPhases}")] public class Continuation : IDisposable { - /// The two-participant barrier that synchronizes phases between the gate holder and its continuation. - private readonly Barrier _phaseSync = new(2); + /// Serializes changes to the current handoff. + private readonly Lock _gate = new(); + + /// The most recently acquired handoff. + private Phase? _phase; + + /// The number of handoffs whose delivery and release have both completed. + private long _completedPhases; /// One once this instance has been disposed; otherwise zero. private int _disposedValue; @@ -23,91 +23,161 @@ public class Continuation : IDisposable /// One while the continuation is locked; otherwise zero. private int _locked; - /// Gets the number of barrier phases that have completed. - public long CompletedPhases => _phaseSync.CurrentPhaseNumber; + /// Whether managed disposal has been requested. + private bool _managedDisposed; - /// Releases the gate, completing the task returned by , and tears down the barrier. + /// Gets the number of handoffs whose delivery and release have both completed. + public long CompletedPhases + { + get + { + lock (_gate) + { + return _completedPhases; + } + } + } + + /// Releases the active handoff and disposes the continuation. public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - /// - /// Takes the gate and hands to paired with this instance as the - /// release handle. A call made while the gate is held emits nothing. - /// + /// Emits an item paired with this release handle, or drops the item while another handoff holds the gate. /// The type of the elements in the source sequence. /// The item handed to the observer. /// The observer receiving the item and its release handle; ignored when . - /// - /// A that completes once the release handle is disposed, or a completed task when the gate was - /// held. - /// + /// A task completed after delivery and release, or a completed task when the item was dropped. public Task Lock(T item, IObserver<(T Value, IDisposable Sync)>? observer) { - if (Interlocked.Exchange(ref _locked, 1) != 0) + var phase = TryBeginPhase(); + if (phase is null) { return Task.CompletedTask; } observer?.OnNext((item, this)); - return ScheduleSignalPhase(); + CompleteDelivery(phase); + return phase.Completion.Task; } - /// -returning counterpart to , for call sites that await the result exactly once. + /// Emits an item with this release handle and returns a value task for its handoff. /// The type of the elements in the source sequence. /// The item handed to the observer. /// The observer receiving the item and its release handle; ignored when . - /// A that completes once the release handle is disposed, or a completed task when the gate was held. + /// A value task completed after delivery and release, or a completed value task when the item was dropped. public ValueTask LockValueTask(T item, IObserver<(T Value, IDisposable Sync)>? observer) { - if (Interlocked.Exchange(ref _locked, 1) != 0) + var handoff = Lock(item, observer); + return ReferenceEquals(handoff, Task.CompletedTask) ? default : new(handoff); + } + + /// Releases the current handoff; repeated releases have no effect. + /// The handoff task, completed once its delivery has returned. + internal Task UnLock() + { + var completion = Task.CompletedTask; + lock (_gate) { - return default; + if (_locked != 0) + { + _locked = 0; + var phase = _phase!; + phase.Released = true; + TryCompletePhase(phase); + completion = phase.Completion.Task; + } } - observer?.OnNext((item, this)); - return new(ScheduleSignalPhase()); + return completion; } - /// Releases the gate and signals the barrier phase; a no-op when the gate is not held. - /// A that completes once the barrier phase is signalled. - internal Task UnLock() => - Interlocked.Exchange(ref _locked, 0) == 0 ? Task.CompletedTask : ScheduleSignalPhase(); + /// Acquires a new handoff while the gate is free. + /// The acquired handoff, or null when another handoff holds the gate. + internal Phase? TryBeginPhase() + { + lock (_gate) + { + if (_locked != 0) + { + return null; + } + + _locked = 1; + _phase = new(_managedDisposed); + return _phase; + } + } + + /// Records delivery completion and completes a handoff that has already been released. + /// The handoff whose observer callback returned. + internal void CompleteDelivery(Phase phase) + { + lock (_gate) + { + phase.Delivered = true; + TryCompletePhase(phase); + } + } - /// Releases unmanaged and - optionally - managed resources. + /// Releases the resources held by this continuation. /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Concurrency", - "SST1905:Do not use async void", - Justification = - "The disposal-pattern overload must return void, and no caller is positioned to observe the awaited barrier teardown.")] - protected virtual async void Dispose(bool disposing) + protected virtual void Dispose(bool disposing) + { + lock (_gate) + { + if (_disposedValue != 0) + { + return; + } + + _disposedValue = 1; + if (!disposing) + { + return; + } + + _managedDisposed = true; + } + + _ = UnLock(); + } + + /// Completes a released delivery once, or faults a handoff acquired after disposal. + /// The handoff to complete. + private void TryCompletePhase(Phase phase) { - if (Interlocked.Exchange(ref _disposedValue, 1) != 0 || !disposing) + if (phase.IsDisposed) { + _ = phase.Completion.TrySetException(new ObjectDisposedException(nameof(Continuation))); return; } - await UnLock().ConfigureAwait(false); - _phaseSync.Dispose(); + if (!phase.Delivered || !phase.Released || phase.Completion.Task.IsCompleted) + { + return; + } + + _completedPhases++; + phase.Completion.SetResult(true); } - /// Signals the phase barrier and waits there for the paired participant. - /// The owning instance. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SignalPhaseSync(object? state) => - ((Continuation)state!)._phaseSync.SignalAndWait(CancellationToken.None); - - /// Runs the barrier signal on the default task scheduler. - /// The task for the scheduled signal work. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private Task ScheduleSignalPhase() => - Task.Factory.StartNew( - SignalPhaseSync, - this, - CancellationToken.None, - TaskCreationOptions.DenyChildAttach, - TaskScheduler.Default); + /// Tracks delivery and release for one acquired handoff. + /// True when the handoff was acquired after managed disposal. + internal sealed class Phase(bool isDisposed) + { + /// Gets the completion shared by the delivery and release participants. + internal TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets a value indicating whether the handoff was acquired after managed disposal. + internal bool IsDisposed { get; } = isDisposed; + + /// Gets or sets a value indicating whether the observer callback returned. + internal bool Delivered { get; set; } + + /// Gets or sets a value indicating whether the handoff was released. + internal bool Released { get; set; } + } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs b/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs index 849d22c4..899b4994 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/CurrentValueSubject.cs @@ -18,7 +18,7 @@ public sealed class CurrentValueSubject : IObservable, IObserver, IDisp /// Single-observer fast path; non-null when exactly one observer is subscribed. private IObserver? _observer; - /// Multi-observer snapshot; non-null when two or more observers are subscribed. Copy-on-write. + /// The observer snapshot, copied on modification and populated when at least two observers subscribe. private IObserver[]? _observers; /// Latest value, replayed to new subscribers. @@ -253,7 +253,6 @@ private void Unsubscribe(IObserver observer) if (existing.Length == 2) { - // Collapse back to the single-observer fast path. _observer = index == 0 ? existing[1] : existing[0]; _observers = null; return; diff --git a/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs b/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs index 1b488707..f4d65e49 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/FirstAsValueTaskHelper.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// -returning counterpart to . The returned value task is -/// backed by a pooled , so each one must be consumed exactly once: awaiting it twice, -/// or reading its result after the backing instance returns to the pool, observes another caller's outcome. -/// +/// Returns the first source value as a ValueTask that must be consumed exactly once. /// The element type. public static class FirstAsValueTaskHelper { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs b/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs index a27b1f82..6a250276 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Heartbeat.cs @@ -4,12 +4,9 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Carries either a heartbeat tick or a value update from an observable sequence. default(Heartbeat<T>) is a -/// value update holding the default ; construct a heartbeat tick with -/// new Heartbeat<T>(). -/// +/// Represents either a heartbeat tick or a value update. /// The type of the update value. +/// The default struct value is an update containing the default T; the parameterless constructor creates a heartbeat tick. [System.Diagnostics.DebuggerDisplay("Heartbeat: IsHeartbeat = {IsHeartbeat}, Update = {Update}")] public readonly record struct Heartbeat : IHeartbeat { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs index 8e26104b..2fb07655 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Internal/ReduceSinkState.cs @@ -44,7 +44,7 @@ public ReduceSinkState(IObserver downstream, int count) /// Gets a value indicating whether every source has produced at least one value. internal bool AllValuesPresent => HasValueCount >= Values.Length; - /// Records source 's latest value and emits the reduced result once every source has one. Runs under the gate. + /// Records the source value and emits the reduction once every source has a value, while the caller holds the gate. /// The 0-based source index that emitted. /// The latest value from that source. /// Projects the per-source latest values into the downstream result. @@ -73,7 +73,7 @@ internal void HandleNext(int index, TIn value, Func reduce) } } - /// Forwards a terminal error to the downstream observer and marks the sink terminal. Idempotent. + /// Forwards the first terminal error and marks the sink terminal. /// The error to forward. internal void HandleError(Exception error) { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs index 15247b6f..9e81b438 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BinaryMinMaxObservable.cs @@ -7,15 +7,12 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Emits the larger or smaller of the latest values from two sources, starting once both have produced one. An error -/// from either source terminates the sequence; completion waits for both sources, or fires as soon as one completes -/// without having emitted. -/// +/// Emits the larger or smaller latest value after both sources have emitted. /// The value type. /// The first source. /// The second source. /// true to emit the maximum; false to emit the minimum. +/// An error terminates immediately; successful completion waits for both sources unless one completes without emitting. [System.Diagnostics.DebuggerDisplay("BinaryMinMaxObservable: Left = {_left}, Right = {_right}")] public sealed class BinaryMinMaxObservable(IObservable left, IObservable right, bool emitMaximum) : IObservable where T : struct, IComparable @@ -68,7 +65,7 @@ private sealed class Sink(IObserver downstream, bool emitMaximum) /// Records one side's latest value and emits the winning comparison once both sides have a value. /// true for the left source. - /// The value. + /// That side's latest value. public void OnNext(bool isLeft, T value) { lock (_gate) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs index 24cd7c7c..a9a210e3 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BooleanReduceObservable.cs @@ -8,13 +8,13 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Emits whether every source's latest boolean equals , re-evaluating on each value once all -/// sources have produced one. An empty source list emits true and completes on subscribe; any source's error -/// terminates the sequence, and the sequence completes when every source completes or one completes without emitting. -/// +/// Emits whether every latest source value equals the target, starting after every source has emitted. /// The source observables. /// The value every source must hold for the operator to emit true. +/// +/// Empty input emits true and completes. An error terminates immediately; otherwise completion waits for all sources unless one completes +/// without emitting. +/// [System.Diagnostics.DebuggerDisplay("BooleanReduceObservable: Sources = {_sourceList}")] public sealed class BooleanReduceObservable(IEnumerable> sources, bool target) : IObservable { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs index 13f90a05..ba52c0a3 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/BufferUntilObservable.cs @@ -7,11 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Buffers characters from through inclusive and emits each -/// delimited run as one string, discarding characters outside a run. Completion flushes a run whose end delimiter never -/// arrived. -/// +/// Emits strings delimited by startsWith and endsWith, including both delimiters and flushing an unfinished string on completion. /// The source observable of characters. /// The starting delimiter. /// The ending delimiter. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs index f132df7d..daa7afb9 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchAndReturnWithFactoryObservable.cs @@ -6,15 +6,12 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Catches , emits a fallback built from it, and completes. Other exception types -/// propagate unchanged, and an exception thrown by terminates the sequence in place -/// of the caught one. -/// +/// Replaces matching errors with a factory-produced fallback value and completes. /// Element type. /// Exception type to catch. /// Upstream source. /// Builds the fallback from the caught exception. +/// Other error types propagate unchanged; a fallback factory failure replaces the caught error. public sealed class CatchAndReturnWithFactoryObservable( IObservable source, Func fallbackFactory) : IObservable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs index 02e171cf..31f422a5 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/CatchIgnoreObservable.cs @@ -6,14 +6,12 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Catches , hands it to and completes. Other exception -/// types propagate unchanged, and an exception thrown by the action terminates the sequence in place of the caught one. -/// +/// Passes matching errors to the action and completes. /// The type of the elements in the source sequence. /// The type of the exception to catch. /// The source observable sequence. /// Action to invoke when an exception of type occurs. +/// Other error types propagate unchanged; an action failure replaces the caught error. [System.Diagnostics.DebuggerDisplay("CatchIgnoreObservable: Source = {_source}, ErrorAction = {_errorAction}")] public sealed class CatchIgnoreObservable( IObservable source, diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs index 0a31b029..f15e3d74 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/DropIfBusyObservable.cs @@ -6,13 +6,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Runs for each source value and forwards the value once it finishes, dropping every -/// value that arrives while an action is in flight. An exception from the action terminates the sequence. -/// +/// Runs the action before forwarding each value, dropping values received while the action is running. /// The element type. /// The source observable. /// The asynchronous action to execute for each forwarded element. +/// An action failure terminates the sequence. public sealed class DropIfBusyObservable(IObservable source, Func asyncAction) : IObservable { /// diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs index 80bbdab0..687f2c3e 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FilterRegexObservable.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Forwards the source strings that matches, dropping non-matches and nulls. An exception -/// raised while matching, such as a regex timeout, terminates the sequence. -/// +/// Forwards non-null strings that match the regex and terminates on matching errors. /// The source observable emitting strings. /// The regex to use for filtering. public sealed class FilterRegexObservable( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs index 2e3d981a..8f6150a3 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/FirstMatchFromCandidatesObservable.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Projects candidates sequentially and emits the first transformed value satisfying the predicate. -/// Projection errors skip the candidate. If none matches, emits the fallback value and completes. -/// +/// Projects candidates sequentially, skipping projection errors, and emits the first matching transformed value or the fallback. /// The type of candidate keys. /// The element type emitted by the projected observable. /// The final result type emitted to downstream after transformation. @@ -19,7 +16,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// Synchronous transform applied to each raw value to produce the result. /// Returns when a transformed value is a match. /// Value emitted when no candidate matches. -/// A projection that completes synchronously runs on the subscribing thread; one that does not keeps the walk alive until its callbacks arrive. +/// +/// Synchronous projections run on the subscribing thread; asynchronous projections continue the candidate search from their callbacks. +/// public sealed class FirstMatchFromCandidatesObservable( IReadOnlyList candidates, Func> project, @@ -240,7 +239,6 @@ public void OnError(Exception error) if (_looping) { - // The walk in TryNext reads this terminal notification off the probe instead. return; } @@ -262,7 +260,6 @@ public void OnCompleted() if (_looping) { - // The walk in TryNext reads this terminal notification off the probe instead. return; } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs index 072f4781..b1302541 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/MinMaxObservable.cs @@ -8,14 +8,14 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Emits the maximum or minimum of the latest values from every source, re-evaluating on each value once all sources -/// have produced one. An empty source list completes on subscribe; any source's error terminates the sequence, and the -/// sequence completes when every source completes or one completes without emitting. -/// +/// Emits the minimum or maximum latest value after every source has emitted. /// The value type. /// The source observables. /// true to emit the maximum; false to emit the minimum. +/// +/// Empty input completes immediately. An error terminates immediately; otherwise completion waits for all sources unless one completes without +/// emitting. +/// [System.Diagnostics.DebuggerDisplay("MinMaxObservable: Sources = {_sourceList.Count}")] public sealed class MinMaxObservable(IReadOnlyList> sources, bool emitMaximum) : IObservable where T : struct, IComparable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs index 6efebca2..d6a352d6 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs @@ -8,12 +8,9 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Splits a sequence into a and a side by predicate. Both sides share one -/// subscription to the source, opened when the first side is subscribed and released when the last subscription is -/// disposed; each value reaches one side only, while an error or completion reaches both. -/// +/// Partitions values by predicate into two outputs that share a source subscription. /// The type of elements in the source sequence. +/// The first subscriber connects and the last disposal disconnects; terminal notifications reach both outputs. [System.Diagnostics.DebuggerDisplay("PartitionObservable: Source = {_source}, Subscriptions = {_subscriptionCount}")] public sealed class PartitionObservable { @@ -106,12 +103,10 @@ public void Dispose() lock (_parent._gate) { - // Subscribe assigns the sink under this lock before returning the handle. _parent._sink!.Remove(_observer, _side); _parent._subscriptionCount--; if (_parent._subscriptionCount == 0) { - // Set alongside the sink under this same lock, so the last disposal finds it non-null. _parent._sourceSubscription!.Dispose(); _parent._sourceSubscription = null; _parent._sink = null; diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs index 87381ba5..aa73d652 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ReplayLastOnSubscribeObservable.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Emits the fixed initial value to each subscriber, then subscribes independently to the source. -/// Late subscribers receive that initial value, not a cached source emission. -/// +/// Emits the fixed initial value to each subscriber before subscribing independently to the source. /// The element type of the source observable. /// The source observable. /// The initial value emitted to every new subscriber. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs index 27dcfb83..dd9c47c3 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SampleLatestObservable.cs @@ -7,14 +7,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Emits the source's latest value each time fires, repeating it when no newer value has -/// arrived and emitting nothing until the source produces its first. An error from either sequence terminates the -/// result; the source's completion completes it, while the trigger's completion is ignored. -/// +/// Emits the latest source value on each trigger after the source first emits. /// The type of elements in the source sequence. /// The source observable. /// The trigger observable. +/// Source completion or either error terminates the result; trigger completion is ignored. public sealed class SampleLatestObservable( IObservable source, IObservable trigger) : IObservable diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs index ed7f452b..ee7dccdb 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ScanWithInitialObservable.cs @@ -6,10 +6,7 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Emits on subscribe, then the running accumulation after each source element. An exception -/// thrown by terminates the sequence and the accumulation stops there. -/// +/// Emits the initial value followed by each accumulation result, terminating if the accumulator throws. /// The type of elements in the source sequence. /// The type of the accumulated value. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs index 2e87fd00..2d044e72 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncConcurrentObservable.cs @@ -5,16 +5,13 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Projects each element through an asynchronous selector with at most operations in -/// flight and queues the rest, so results arrive in completion order rather than source order. The first selector -/// failure terminates the sequence, and the source's completion is held back until the queue drains. -/// +/// Queues asynchronous projections with bounded concurrency and emits results in completion order. /// The type of elements in the source sequence. /// The type of the result of the asynchronous operation. /// The source observable. /// The asynchronous projection function. /// The maximum number of concurrent operations. +/// Selector failure terminates immediately; source completion waits for queued projections to finish. public sealed class SelectAsyncConcurrentObservable(IObservable source, Func> selector, int maxConcurrency) : IObservable { /// diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs index cee3b71a..8bd0afcb 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectAsyncSequentialObservable.cs @@ -5,15 +5,12 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Projects each element through an asynchronous selector one operation at a time, queueing values that arrive while an -/// operation runs so results keep source order. The first selector failure terminates the sequence, and the source's -/// completion waits for the queue to drain. -/// +/// Queues asynchronous projections and emits results in source order. /// The type of elements in the source sequence. /// The type of the result of the asynchronous operation. /// The source observable. /// The asynchronous projection function. +/// Selector failure terminates immediately; source completion waits for queued projections to finish. public sealed class SelectAsyncSequentialObservable(IObservable source, Func> selector) : IObservable { /// diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs index 47ad790a..13c21c62 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectConstantObservable.cs @@ -23,7 +23,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new SelectConstantWitness(observer, constant)); } - /// Forwarding observer that replaces every value with the stored constant. Error and completion signals pass through unchanged. + /// Replaces values with the stored constant and forwards terminal notifications unchanged. /// The downstream observer. /// The constant value to emit. private sealed class SelectConstantWitness(IObserver downstream, TResult constant) : IObserver diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs index 219328db..17011d25 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectLatestAsyncObservable.cs @@ -5,15 +5,12 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Projects each element through an asynchronous selector and emits only the latest projection's result. A superseded -/// operation keeps running but its result and its failure are both discarded, and the source's completion is deferred -/// until the latest projection finishes. -/// +/// Emits only the latest asynchronous projection, discarding superseded results and errors. /// The type of elements in the source sequence. /// The type of the result of the asynchronous operation. /// The source observable. /// The asynchronous projection function. +/// Superseded operations continue running; source completion waits for the latest projection. public sealed class SelectLatestAsyncObservable(IObservable source, Func> selector) : IObservable { /// @@ -91,7 +88,7 @@ public void OnCompleted() return; } - _ = toAwait.ContinueWith(static (_, s) => ((SelectLatestAsyncSink)s!).SignalCompleted(), this, TaskScheduler.Default); + RegisterCompletion(toAwait, this); } /// @@ -143,6 +140,14 @@ internal void SignalCompleted() } } + /// Registers completion delivery for the pending projection. + /// The pending projection. + /// The completion recipient. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static void RegisterCompletion(Task task, SelectLatestAsyncSink sink) => + _ = task.ContinueWith(static (_, state) => ((SelectLatestAsyncSink)state!).SignalCompleted(), sink, TaskScheduler.Default); + /// Awaits the selector and emits or faults only while this operation is the latest one. /// The value to project. /// The ID of this operation. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs index 222b2a3e..e52ffd7d 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SelectManyThenObservable.cs @@ -6,17 +6,14 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Projects each source element through and each intermediate element through -/// , emitting the second stage's values. An exception from either projection terminates the -/// sequence, and the first inner sequence to complete completes the result. -/// +/// Projects each source value through two successive observable selectors and forwards the second-stage values. /// The source element type. /// The intermediate element type produced by the first projection. /// The final element type produced by the second projection. /// The source observable. /// First projection: source element → intermediate observable. /// Second projection: intermediate element → result observable. +/// A selector failure terminates the sequence; the first inner completion completes the result. public sealed class SelectManyThenObservable( IObservable source, Func> first, diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs index bb74a950..43c2e539 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/ShuffleObservable.cs @@ -7,10 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Reorders each array the source emits in place with non-cryptographic randomness and forwards that same instance, so -/// any other holder of the array sees the new order. A null array is forwarded untouched. -/// +/// Shuffles each array in place with non-cryptographic randomness and forwards the same instance, passing null through unchanged. /// The array element type. /// The source observable emitting arrays. public sealed class ShuffleObservable(IObservable source) : IObservable @@ -48,11 +45,7 @@ public void OnNext(T[] value) return; } -#if NET8_0_OR_GREATER - Random.Shared.Shuffle(value); -#else ShuffleInPlace(value); -#endif downstream.OnNext(value); } @@ -65,24 +58,28 @@ public void OnNext(T[] value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void OnCompleted() => downstream.OnCompleted(); -#if !NET8_0_OR_GREATER - /// Fisher-Yates over a per-thread for targets without Random.Shuffle. + /// Randomly reorders the array in place. /// The array to shuffle in place. +#if NET8_0_OR_GREATER + [ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ShuffleInPlace(T[] array) => Random.Shared.Shuffle(array); +#else private static void ShuffleInPlace(T[] array) { - var random = _threadRandom; - if (random is null) - { - random = new(); - _threadRandom = random; - } - for (var n = array.Length - 1; n > 0; n--) { - var k = random.Next(n + 1); + var k = NextIndex(n + 1); (array[n], array[k]) = (array[k], array[n]); } } + + /// Returns a random index below the exclusive bound. + /// The exclusive upper bound. + /// A nonnegative index below the bound. + [ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int NextIndex(int exclusiveUpperBound) => (_threadRandom ??= new()).Next(exclusiveUpperBound); #endif } } diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs index b4d9a622..ff15651d 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SubscribeAsyncObservable.cs @@ -6,12 +6,11 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Subscribes to a source and runs an asynchronous handler for each element, one at a time, queueing values that arrive -/// while a handler runs. A handler failure goes to the error callback and stops further processing; the completion -/// callback runs once the queue drains after the source completes, and disposal drops the subscription and the queue. -/// +/// Queues source values and invokes the asynchronous handler one value at a time. /// The type of elements in the source sequence. +/// +/// Handler failure invokes the error callback and stops processing. Completion waits for queued values; disposal drops them and unsubscribes. +/// [System.Diagnostics.DebuggerDisplay("SubscribeAsyncObservable: Queued = {_queue.Count}, Processing = {_isProcessing}, Done = {_done}")] public sealed class SubscribeAsyncObservable : IDisposable { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs index d5924f09..f9f096d0 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SwitchIfEmptyObservable.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Forwards the source's values, and when the source completes without having emitted any, subscribes -/// and forwards that sequence instead. A source error propagates without the fallback being -/// tried. -/// +/// Subscribes to the fallback only when the source completes without emitting, propagating source errors unchanged. /// The type of elements in the source sequence. /// The source observable. /// The fallback observable. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs index eecb42ab..70c54aaf 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/SynchronizeAsyncObservable.cs @@ -6,12 +6,10 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Forwards each source value paired with a fresh disposable handle the consumer disposes to acknowledge it. Each value -/// carries its own handle, and a handle left undisposed leaves only its own acknowledgement wait outstanding. -/// +/// Pairs each source value with an independent disposable acknowledgement handle. /// The type of elements in the source sequence. /// The source observable. +/// An undisposed handle leaves only its own acknowledgement pending. public sealed class SynchronizeAsyncObservable(IObservable source) : IObservable<(T Value, IDisposable Sync)> { /// @@ -39,7 +37,7 @@ internal sealed class SynchronizeAsyncSink(IObserver<(T Value, IDisposable Sync) private bool _disposed; /// - /// The value. + /// The value to forward. public void OnNext(T value) { lock (_gate) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs index 86cd9e05..366203c1 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TakeUntilInclusiveObservable.cs @@ -4,10 +4,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; -/// -/// Forwards source elements until returns , emits that element too, -/// then completes. An exception from the predicate terminates the sequence before that element is emitted. -/// +/// Emits values through the first predicate match and completes, terminating before emission if the predicate throws. /// The type of elements in the source sequence. /// The source observable. /// The predicate to determine when to stop taking elements. @@ -39,7 +36,7 @@ private sealed class TakeUntilInclusiveWitness( private bool _done; /// - /// The value. + /// The value to forward. public void OnNext(T value) { lock (_gate) diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs index fa5c7138..970abe44 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/TrySelectObservable.cs @@ -24,7 +24,7 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new TrySelectWitness(observer, selector)); } - /// Observer that applies the selector and only forwards non-null results. Exceptions from the selector are routed to . + /// Projects values and forwards non-null results, reporting selector exceptions through OnError. /// The downstream observer. /// The projection delegate. private sealed class TrySelectWitness( diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs index 66c49651..60f7a5c0 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/WaitUntilObservable.cs @@ -7,9 +7,7 @@ namespace ReactiveUI.Primitives.Extensions.Operators; /// -/// Emits the first source value that satisfies , completes, and disposes the source -/// subscription. An exception from the predicate terminates the sequence, and the source completing first ends the -/// sequence without a value. +/// Emits the first predicate match and completes, propagating predicate failures and completing without a value if the source ends first. /// /// The element type of the source observable. /// The source observable. diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs b/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs index 4563c921..f7a94a83 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Stale.cs @@ -4,12 +4,12 @@ namespace ReactiveUI.Primitives.Extensions; -/// -/// Carries either a staleness signal or a value update from an observable sequence. default(Stale<T>) is a -/// value update holding the default ; construct a staleness signal with -/// new Stale<T>(), and read only when is . -/// +/// Represents either a staleness notification or a value update. /// The type of the update value. +/// +/// The default struct value is an update containing the default T; the parameterless constructor creates a staleness notification. Read Update +/// only when IsStale is false. +/// [System.Diagnostics.DebuggerDisplay("Stale: IsStale = {IsStale}")] public readonly record struct Stale : IStale { diff --git a/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs b/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs index ec8bf461..c319844e 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/TimerSinkState.cs @@ -24,7 +24,7 @@ public sealed class TimerSinkState(IObserver downstream) /// Gets a value indicating whether the sink has terminated through error, completion or disposal; read it under the owning sink's gate. public bool Done { get; private set; } - /// Forwards a terminal error to the downstream observer and tears the sink down. The caller must hold the sink's gate. + /// Forwards an error and disposes the sink while the caller holds its gate. /// The error to forward. public void HandleErrorLocked(Exception error) { @@ -38,7 +38,7 @@ public void HandleErrorLocked(Exception error) downstream.OnError(error); } - /// Forwards completion to the downstream observer and tears the sink down. The caller must hold the sink's gate. + /// Forwards completion and disposes the sink while the caller holds its gate. public void HandleCompletedLocked() { if (Done) @@ -51,7 +51,7 @@ public void HandleCompletedLocked() downstream.OnCompleted(); } - /// Marks the sink terminal and disposes the timer without forwarding a notification. The caller must hold the sink's gate. + /// Marks the sink terminal and disposes its timer without notification, while the caller holds its gate. public void HandleDisposeLocked() { Done = true; diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs index c159f795..ebb5e1ae 100644 --- a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs @@ -8,9 +8,7 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// MAUI dispatcher scheduler that coalesces scheduled work through an . -/// Work runs on the dispatcher's thread. Delayed work goes to the dispatcher's own delayed dispatch, which -/// cannot be called off once armed, so disposing the returned subscription suppresses the action without cancelling -/// the delay. +/// Callbacks run on the dispatcher thread; cancellation suppresses delayed actions without cancelling the underlying delay. /// [System.Diagnostics.DebuggerDisplay("MauiDispatcherSequencer: Dispatcher = {Dispatcher}")] public sealed class MauiDispatcherSequencer : CoalescingDispatchScheduler @@ -32,7 +30,7 @@ protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTim { _ = Dispatcher.DispatchDelayed(dueTime, work); - // The dispatcher hands back no handle for an armed delay, so there is nothing to stop here. + // Cancelling suppresses delivery while the requested delay remains scheduled. return Disposable.Empty; } } diff --git a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs index ee33076d..ad7aa548 100644 --- a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs @@ -9,9 +9,10 @@ namespace ReactiveUI.Primitives.Concurrency; /// MAUI dispatcher sequencer that coalesces scheduled work through an . -/// Work runs on the dispatcher's thread, one batch per posted drain; scheduling from that thread queues the -/// item for the next drain rather than running it inline. Delayed work goes to the dispatcher's own delayed dispatch, -/// which cannot be called off once armed, so a cancelled item is skipped when the delay elapses. +/// +/// Callbacks run in posted dispatcher batches without inline reentrancy; cancellation suppresses delayed actions without cancelling the +/// underlying delay. +/// /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class MauiDispatcherSequencer : ISequencer diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs index 54d95eb6..1a45a377 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs @@ -30,7 +30,6 @@ internal static string Emit(ImmutableArray models) var builder = new PooledStringBuilder(ScaffoldCapacity + (models.Length * OverloadCapacity)); _ = builder.Append(Constants.GeneratedFileHeader); - // Every overload was extracted from the same compilation, so they agree on what its language allows. if (models[0].SupportsNullableAnnotations) { _ = builder.Append(Constants.NullableEnableDirective); diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs index 77aa8837..e372a475 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs @@ -71,8 +71,7 @@ private static void AppendSanitized(PooledStringBuilder builder, string identity var start = 0; var end = identity.Length; - // Leading and trailing punctuation would sanitize to uninformative underscores; the hash separates - // identities that differ only there. + // Identity differences in leading or trailing punctuation contribute to the generated name. while (start < end && !char.IsLetterOrDigit(identity[start])) { start++; diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs index 6f269ca7..ac04fd50 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs @@ -246,7 +246,6 @@ private void EnsureCapacity(int required) return; } - // Doubling unless the caller asked for more outright, so a run of small appends does not re-rent per append. var next = RentBuffer(Math.Max(required, _buffer.Length * GrowthFactor)); Array.Copy(_buffer, next, _position); var toReturn = _buffer; diff --git a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs index bbc69cf5..c86a26a5 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Primitives.ObservableEvents; /// Generates observable wrappers for the event-bearing types a consumer asks for. -/// Requests come from Events() calls or static-host attributes. Output is cached per host, namespace, and activation signature. +/// Requests come from Events() calls or static-host attributes. [Generator(LanguageNames.CSharp)] public sealed class EventGenerator : IIncrementalGenerator { @@ -21,7 +21,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { RegisterActivationOutput(in context); - // Resolve provider names independently so reference changes do not invalidate event extraction. var provider = context.CompilationProvider .Select(static (compilation, _) => ProviderResolver.Resolve(compilation)) .WithTrackingName(GeneratorStepNames.Provider); @@ -72,7 +71,6 @@ private static void RegisterInstanceOutputs( targets.Combine(provider), static (output, data) => EmitInstanceTarget(in output, data.Left, data.Right)); - // Keyed on the overload signatures alone, so changing what a wrapper exposes leaves this file untouched. context.RegisterSourceOutput( targets .Where(static target => !target.Events.IsEmpty) diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs index 8659b6c1..a1c9fdba 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs @@ -71,7 +71,7 @@ private static InstanceTargetModel Create( var typeParameterList = SymbolHelpers.BuildTypeParameterList(typeParameters, typeParameterNames); var typeReference = SymbolHelpers.Display(host, typeParameterNames, supportsNullableAnnotations); - // Keyed on the unannotated name, so a generated file does not change identity with the language version. + // Generated file identity is independent of nullable annotations. var identity = host.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var displayName = host.ToDisplayString(); var namespaceName = host.ContainingNamespace.IsGlobalNamespace diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs index b9a0c997..89e2349b 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs @@ -169,7 +169,6 @@ internal static string EscapeIdentifier(string value) => /// The escaped text, or the original instance when nothing needed escaping. internal static string EscapeXml(string value) { - // Most references have nothing to escape, so the scan is what keeps the common case allocation-free. if (value.IndexOfAny(XmlSpecialCharacters) < 0) { return value; @@ -241,7 +240,7 @@ private static string SelectPrimaryConstraint(ITypeParameterSymbol parameter, bo if (parameter.HasReferenceTypeConstraint) { - // Omit referenced nullable constraints when the consumer's language cannot express them. + // Generated constraints omit nullable syntax unsupported by the consumer language version. return supportsNullableAnnotations && parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated ? "class?" diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs index 6b8a3ed4..dc7c1796 100644 --- a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs +++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs @@ -80,7 +80,6 @@ internal static StaticNamespaceModel[] GroupByNamespace(ImmutableArray -/// Schedules work on a single-threaded event loop without blocking or starting threads. Immediate work runs in -/// batches between event-loop turns; delayed and periodic work use timers. Long-running scheduling is unsupported. -/// +/// Schedules immediate batches and timed work on a single-threaded event loop. +/// Immediate batches yield between event-loop turns; long-running scheduling is unsupported. [System.Diagnostics.DebuggerDisplay("WasmScheduler: ReadyCount = {_readyCount}, DrainState = {_drainState}, Disposed = {_isDisposed}")] public sealed class WasmScheduler : LocalScheduler, ISchedulerPeriodic, IDisposable { @@ -54,14 +53,15 @@ public sealed class WasmScheduler : LocalScheduler, ISchedulerPeriodic, IDisposa internal WasmScheduler(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; - _drainTimer = _timeProvider.CreateTimer( + _drainTimer = CreateTimer( + _timeProvider, static state => ((WasmScheduler)state!).RunDrain(), this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } - /// A queued work item awaiting an event-loop drain or a one-shot timer. Disposing it cancels it. + /// Represents queued work that disposal cancels before execution. internal interface IReadyWorkItem : IDisposable { /// Runs the scheduled action unless cancelled. @@ -115,17 +115,11 @@ public override IDisposable Schedule( var item = new StatefulWorkItem(this, state, action); - // The timer roots itself while armed through the callback's target (the work item), which stores the - // timer; the item's Dispose cancels and releases it. - item.AttachTimer(_timeProvider.CreateTimer(static s => ((IReadyWorkItem)s!).Run(), item, dt, Timeout.InfiniteTimeSpan)); + item.AttachTimer(CreateTimer(_timeProvider, static s => ((IReadyWorkItem)s!).Run(), item, dt, Timeout.InfiniteTimeSpan)); return item; } - /// - /// Schedules a periodic action. Periods below one millisecond (including zero) are clamped to one millisecond: - /// a tight sequential loop would starve a single-threaded event loop, and browsers clamp nested - /// setTimeout anyway. - /// + /// Schedules a periodic action, clamping periods below one millisecond to one millisecond. /// The type of the state passed to the action. /// Initial state passed to the action upon the first iteration. /// Period for running the work periodically. @@ -148,10 +142,8 @@ public IDisposable SchedulePeriodic(TState state, TimeSpan period, Func< return PeriodicWorkItem.Start(state, period, action, _timeProvider); } - /// - /// Releases the drain timer and cancels queued work. Subsequent scheduling throws ObjectDisposedException. - /// Running work completes; callers retain responsibility for cancelling delayed work through its returned handle. - /// + /// Cancels queued immediate work and rejects further scheduling, allowing running work to finish. + /// Delayed work remains owned by its returned cancellation handle. public void Dispose() { if (Interlocked.Exchange(ref _isDisposed, 1) != 0) @@ -170,7 +162,6 @@ internal void Enqueue(IReadyWorkItem item) QueueReady(item); PostDrain(); - // Release work enqueued after disposal drained the queue. if (!IsDisposed) { return; @@ -223,6 +214,26 @@ internal bool TryPostDrain(int observedState) return true; } + /// Registers a timer callback with the supplied provider. + /// The timer provider. + /// The callback invoked when due. + /// The callback state. + /// The initial delay. + /// The repeat interval. + /// The timer's cancellation handle. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ITimer CreateTimer(TimeProvider provider, TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period) => + provider.CreateTimer(callback, state, dueTime, period); + + /// Updates a timer's next firing and repeat interval. + /// The timer to update. + /// The initial delay. + /// The repeat interval. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ChangeTimer(ITimer timer, TimeSpan dueTime, TimeSpan period) => _ = timer.Change(dueTime, period); + /// Cancels and removes every queued work item. private void ReleaseReady() { @@ -248,14 +259,13 @@ private void PostDrain() /// Yields the claimed drain batch to the event loop, or hands the latch back when disposal beat it. private void ArmDrain() { - // Release the drain claim when disposal prevents arming the timer. if (IsDisposed) { Volatile.Write(ref _drainState, DrainIdle); return; } - _ = _drainTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + ChangeTimer(_drainTimer, TimeSpan.Zero, Timeout.InfiniteTimeSpan); } /// Drains queued batches until no further pass is requested. @@ -263,11 +273,8 @@ private void RunDrain() { do { - // Claim this pass; a concurrent PostDrain that observes DrainRunning will bump it to DrainRunningPending. Volatile.Write(ref _drainState, DrainRunning); RunReadyBatch(); - - // Finish only when no work was flagged during this pass. } while (Interlocked.CompareExchange(ref _drainState, DrainIdle, DrainRunning) != DrainRunning); @@ -276,7 +283,6 @@ private void RunDrain() return; } - // Cover the narrow window where an item was enqueued but its PostDrain has not run yet. PostDrain(); } @@ -299,7 +305,6 @@ public StatefulWorkItem(WasmScheduler scheduler, TState state, Func public void Dispose() { - // Publish cancellation before reclaiming timers so a concurrent attachment releases its handle. if (!TryClaimDispose()) { return; @@ -314,12 +319,18 @@ public void Dispose() internal void AttachTimer(IDisposable timer) { Volatile.Write(ref _timer, timer); + ReleaseCanceledTimer(); + } + + /// Releases the attached timer when the work item is cancelled. + internal void ReleaseCanceledTimer() + { if (!IsDisposed) { return; } - timer.Dispose(); + Interlocked.Exchange(ref _timer, null)?.Dispose(); } } @@ -363,7 +374,6 @@ public void Dispose() _isDisposed = true; - // Start assigns the timer before returning; only the first Dispose reaches this point. _timer!.Dispose(); _timer = null; _state = default!; @@ -383,14 +393,15 @@ internal static PeriodicWorkItem Start( TimeProvider timeProvider) { PeriodicWorkItem item = new(state, action); - var timer = timeProvider.CreateTimer( + var timer = CreateTimer( + timeProvider, static s => ((PeriodicWorkItem)s!).Tick(), item, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); item._timer = timer; - _ = timer.Change(period, period); + ChangeTimer(timer, period, period); return item; } diff --git a/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs b/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs index 02ce2775..897e53a7 100644 --- a/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs +++ b/src/ReactiveUI.Primitives.Reactive/Disposables/ContainerDisposable.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Reactive.Disposables; -/// -/// A that a System.Reactive consumer can use as a -/// , so an activation-scoped container flows into APIs written against -/// System.Reactive - DisposeWith above all - without the caller converting it by hand. -/// +/// Holds disposables and supports implicit conversion to a System.Reactive composite disposable. /// Conversions reuse a composite owned by the container. Composite registrations occupy one container slot and are not individually visible through Count, Contains, or Remove. [System.Diagnostics.DebuggerDisplay("ContainerDisposable: Count = {Count}, IsDisposed = {IsDisposed}")] public sealed class ContainerDisposable : MultipleDisposable @@ -77,7 +73,7 @@ public CompositeDisposable ToCompositeDisposable() var created = new CompositeDisposable(); _composite = created; - // Register the composite so its lifetime follows the container. + // Composite disposal follows container disposal. Add(created); return created; } @@ -88,7 +84,7 @@ protected override void Dispose(bool disposing) { base.Dispose(disposing); - // The container owns the composite; repeated disposal is harmless. + // Repeated composite disposal has no effect. _composite?.Dispose(); } } diff --git a/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs b/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs index 56783db4..ca7c51ca 100644 --- a/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs +++ b/src/ReactiveUI.Primitives.Reactive/Disposables/LinqExtensions.ContainerDisposable.cs @@ -19,7 +19,7 @@ public static partial class LinqExtensions /// The container. /// The original disposable. /// is . - /// This exact container overload resolves DisposeWith calls when System.Reactive's composite overload is also in scope. + /// This overload resolves DisposeWith calls when System.Reactive also supplies a composite overload. public T DisposeWith(ContainerDisposable disposables) { ArgumentExceptionHelper.ThrowIfNull(disposables); diff --git a/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs b/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs index 5af649b3..6608b865 100644 --- a/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs +++ b/src/ReactiveUI.Primitives.Reactive/Platforms/android/HandlerSequencer.cs @@ -3,16 +3,12 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Disposables; +using System.Runtime.CompilerServices; using Android.OS; namespace ReactiveUI.Primitives.Reactive.Concurrency; -/// -/// System.Reactive-flavoured Android scheduler that coalesces scheduled work onto the thread backing a -/// (typically the main/UI looper). Immediate work is batched through a single cached -/// drain, so the per-post path allocates nothing; delayed work uses the native -/// . -/// +/// Schedules immediate and delayed work on the Android handler thread. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class HandlerSequencer : CoalescingDispatchScheduler @@ -47,13 +43,30 @@ public HandlerSequencer(Handler handler) => protected override bool Post(Action drain) { _drainRunnable ??= new Java.Lang.Runnable(drain); - return Handler.Post(_drainRunnable); + return PostToHandler(_drainRunnable); } /// protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) { var runnable = new Java.Lang.Runnable(work); + return ScheduleOnHandler(runnable, dueTime); + } + + /// Posts the runnable through the native handler. + /// The runnable to post. + /// Whether the handler accepted the runnable. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool PostToHandler(Java.Lang.IRunnable runnable) => Handler.Post(runnable); + + /// Schedules cancellable work through the native handler. + /// The callback to run. + /// The requested delay. + /// The callback cancellation handle. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private IDisposable ScheduleOnHandler(Java.Lang.IRunnable runnable, TimeSpan dueTime) + { _ = Handler.PostDelayed(runnable, (long)dueTime.TotalMilliseconds); return Disposable.Create((Handler, runnable), static state => state.Handler.RemoveCallbacks(state.runnable)); } diff --git a/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs b/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs index eff3ce0a..b6650359 100644 --- a/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs +++ b/src/ReactiveUI.Primitives.Reactive/Platforms/apple/NSRunloopSequencer.cs @@ -3,17 +3,13 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Disposables; +using System.Runtime.CompilerServices; using CoreFoundation; namespace ReactiveUI.Primitives.Reactive.Concurrency; -/// -/// System.Reactive-flavoured Apple scheduler that coalesces scheduled work onto the main -/// (the UI thread on iOS, tvOS, Mac Catalyst, and macOS). Immediate work is batched through a single cached -/// drain, so the per-post path allocates nothing; delayed work uses -/// . -/// +/// Schedules immediate and delayed work on the Apple main dispatch queue. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class NSRunloopSequencer : CoalescingDispatchScheduler @@ -43,7 +39,7 @@ private NSRunloopSequencer() protected override bool Post(Action drain) { _drainBlock ??= new DispatchBlock(drain); - DispatchQueue.MainQueue.DispatchAsync(_drainBlock); + DispatchOnMainQueue(_drainBlock); return true; } @@ -51,6 +47,22 @@ protected override bool Post(Action drain) protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) { var block = new DispatchBlock(work); + return ScheduleOnMainQueue(block, dueTime); + } + + /// Posts the block through the native main queue. + /// The callback block to post. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void DispatchOnMainQueue(DispatchBlock block) => DispatchQueue.MainQueue.DispatchAsync(block); + + /// Schedules a cancellable block through the native main queue. + /// The callback block to run. + /// The requested delay. + /// The block cancellation handle. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static IDisposable ScheduleOnMainQueue(DispatchBlock block, TimeSpan dueTime) + { var nanoseconds = (long)dueTime.TotalMilliseconds * NanosecondsPerMillisecond; DispatchQueue.MainQueue.DispatchAfter(new(DispatchTime.Now, nanoseconds), block); return Disposable.Create(block, static b => b.Cancel()); diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs index 24c38195..98abc213 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs @@ -2,47 +2,65 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive.Concurrency; using System.Runtime.CompilerServices; namespace ReactiveUI.Primitives.Reactive.Concurrency; /// Windows Forms scheduler that coalesces scheduled work through a UI control. -/// Work runs on the control's UI thread. Work scheduled before the control has a handle stays queued until -/// handle creation posts a drain, and delayed work waits on a background scheduler that marshals it back through the -/// control when due. +/// Callbacks run on the control thread; work queued before handle creation waits for the handle. /// [System.Diagnostics.DebuggerDisplay("ControlSequencer: Control = {Control}")] public sealed class ControlSequencer : CoalescingDispatchScheduler { + /// Optional callback for posting ready work. + private readonly Func? _post; + /// Initializes a new instance of the class. /// The control used to marshal work to the UI thread. /// is . public ControlSequencer(Control control) + : this(control, null, null) + { + } + + /// Initializes a new instance of the class. + /// The control associated with this scheduler. + /// Posts ready work, or null to use the control. + /// Schedules delayed work, or null to use the default scheduler. + /// The control is null. + internal ControlSequencer(Control control, Func? post, IScheduler? delayScheduler) + : base(delayScheduler ?? DefaultScheduler.Instance) { Control = control ?? throw new ArgumentNullException(nameof(control)); + _post = post; Control.HandleCreated += OnHandleCreated; } /// Gets the control used to marshal work to the UI thread. public Control Control { get; } - /// - /// The control has been disposed. - protected override bool Post(Action drain) + /// Posts work when the control is live and its handle is available. + /// The control whose disposal state is checked. + /// Whether the control handle is available. + /// The callback to post. + /// Posts the callback to the control. + /// False if the handle is unavailable or disappears while posting. + internal static bool TryPost(Control control, bool handleCreated, Action drain, Action beginInvoke) { - ObjectDisposedExceptionHelper.ThrowIf(Control.IsDisposed, Control); + ObjectDisposedExceptionHelper.ThrowIf(control.IsDisposed, control); - if (!Control.IsHandleCreated) + if (!handleCreated) { return false; } try { - _ = Control.BeginInvoke(drain); + beginInvoke(control, drain); return true; } - catch (InvalidOperationException) when (!Control.IsDisposed) + catch (InvalidOperationException) when (!control.IsDisposed) { return false; } @@ -52,5 +70,16 @@ protected override bool Post(Action drain) /// The event source. /// The event arguments. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnHandleCreated(object? sender, EventArgs e) => RequestDrain(); + internal void OnHandleCreated(object? sender, EventArgs e) => RequestDrain(); + + /// + /// The control has been disposed. + protected override bool Post(Action drain) => + _post is null ? TryPost(Control, Control.IsHandleCreated, drain, BeginInvoke) : _post(drain); + + /// Invokes the platform's asynchronous control dispatch. + /// The target control. + /// The callback to post. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void BeginInvoke(Control control, Action drain) => _ = control.BeginInvoke(drain); } diff --git a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs index 4d883877..0187d28f 100644 --- a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs +++ b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs @@ -8,14 +8,16 @@ namespace ReactiveUI.Primitives.Concurrency; /// Windows Forms sequencer that coalesces scheduled work through a UI control. -/// Work runs on the control's UI thread, one batch per posted drain; scheduling from that thread queues the -/// item for the next drain rather than running it inline. Work scheduled before the control has a handle stays queued -/// until handle creation posts a drain, and delayed work waits on a shared timer that marshals it back through the -/// control when due. +/// +/// Callbacks run in posted control-thread batches without inline reentrancy; work queued before handle creation waits for the handle. +/// /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ControlSequencer : ISequencer { + /// Optional callback for posting ready work. + private readonly Func? _post; + /// Coalescing dispatch engine. private DispatchSequencerState _state; @@ -23,9 +25,20 @@ public sealed class ControlSequencer : ISequencer /// The control used to marshal work to the UI thread. /// is . public ControlSequencer(Control control) + : this(control, null, null) + { + } + + /// Initializes a new instance of the class. + /// The control associated with this sequencer. + /// Posts ready work, or null to use the control. + /// Schedules delayed work, or null to use the shared timer. + /// The control is null. + internal ControlSequencer(Control control, Func? post, Action? scheduleDelayed) { Control = control ?? throw new ArgumentNullException(nameof(control)); - _state = new(this, Post, RunDrain); + _post = post; + _state = new(this, Post, RunDrain, scheduleDelayed); Control.HandleCreated += OnHandleCreated; } @@ -50,37 +63,52 @@ public ControlSequencer(Control control) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp); - /// Marshals the cached drain callback onto the control's UI thread. - /// The drain callback. - /// when the drain was posted; otherwise, . - /// The control has been disposed. - private bool Post(Action drain) + /// Posts work when the control is live and its handle is available. + /// The control whose disposal state is checked. + /// Whether the control handle is available. + /// The callback to post. + /// Posts the callback to the control. + /// False if the handle is unavailable or disappears while posting. + internal static bool TryPost(Control control, bool handleCreated, Action drain, Action beginInvoke) { - ObjectDisposedExceptionHelper.ThrowIf(Control.IsDisposed, Control); + ObjectDisposedExceptionHelper.ThrowIf(control.IsDisposed, control); - if (!Control.IsHandleCreated) + if (!handleCreated) { return false; } try { - _ = Control.BeginInvoke(drain); + beginInvoke(control, drain); return true; } - catch (InvalidOperationException) when (!Control.IsDisposed) + catch (InvalidOperationException) when (!control.IsDisposed) { return false; } } - /// Runs one queued batch on the coalescing engine. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RunDrain() => _state.RunDrain(); - /// Posts queued work once the control handle becomes available. /// The event source. /// The event arguments. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnHandleCreated(object? sender, EventArgs e) => _state.PostDrain(); + internal void OnHandleCreated(object? sender, EventArgs e) => _state.PostDrain(); + + /// Invokes the platform's asynchronous control dispatch. + /// The target control. + /// The callback to post. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static void BeginInvoke(Control control, Action drain) => _ = control.BeginInvoke(drain); + + /// Marshals the cached drain callback onto the control's UI thread. + /// The drain callback. + /// True when the drain was posted. + /// The control has been disposed. + private bool Post(Action drain) => + _post is null ? TryPost(Control, Control.IsHandleCreated, drain, BeginInvoke) : _post(drain); + + /// Runs one queued batch on the coalescing engine. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RunDrain() => _state.RunDrain(); } diff --git a/src/ReactiveUI.Primitives.WinForms/Properties/AssemblyInfo.cs b/src/ReactiveUI.Primitives.WinForms/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..cd293f13 --- /dev/null +++ b/src/ReactiveUI.Primitives.WinForms/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ReactiveUI.Primitives.WinForms.Tests")] diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs index 23724cdc..7ff954f9 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs @@ -3,17 +3,23 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Disposables; +using System.Runtime.CompilerServices; using Microsoft.UI.Dispatching; namespace ReactiveUI.Primitives.Reactive.Concurrency; /// WinUI dispatcher queue scheduler that coalesces scheduled work through a . -/// Work runs on the dispatcher queue's thread and delayed work fires on a dispatcher queue timer, so disposing -/// the returned subscription stops that timer as well as suppressing work that has not started. +/// Callbacks run on the dispatcher queue thread; cancellation stops pending timers and suppresses unstarted actions. /// [System.Diagnostics.DebuggerDisplay("DispatcherQueueSequencer: DispatcherQueue = {DispatcherQueue}, Priority = {Priority}")] public sealed class DispatcherQueueSequencer : CoalescingDispatchScheduler { + /// Optional callback for enqueueing native drain delegates. + private readonly Func? _tryEnqueue; + + /// Optional callback for delayed work. + private readonly Func? _scheduleDelayed; + /// Cached dispatcher queue handler used for the drain. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", @@ -39,6 +45,21 @@ public DispatcherQueueSequencer(DispatcherQueue dispatcherQueue, DispatcherQueue Priority = priority; } + /// Initializes a new instance of the class. + /// Priority passed to the enqueue callback. + /// Attempts to enqueue each drain. + /// Schedules delayed work. + internal DispatcherQueueSequencer( + DispatcherQueuePriority priority, + Func tryEnqueue, + Func scheduleDelayed) + { + DispatcherQueue = null!; + Priority = priority; + _tryEnqueue = tryEnqueue; + _scheduleDelayed = scheduleDelayed; + } + /// Gets the dispatcher queue used to marshal work to the UI thread. public DispatcherQueue DispatcherQueue { get; } @@ -50,7 +71,7 @@ public DispatcherQueueSequencer(DispatcherQueue dispatcherQueue, DispatcherQueue protected override bool Post(Action drain) { _handler ??= drain.Invoke; - if (DispatcherQueue.TryEnqueue(Priority, _handler)) + if (_tryEnqueue is null ? TryEnqueue(_handler) : _tryEnqueue(Priority, _handler)) { return true; } @@ -60,7 +81,15 @@ protected override bool Post(Action drain) /// [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) + protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) => + _scheduleDelayed is null ? StartDispatcherTimer(work, dueTime) : _scheduleDelayed(work, dueTime); + + /// Schedules a cancellable native dispatcher timer. + /// The callback to run. + /// The requested delay. + /// The timer cancellation handle. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private IDisposable StartDispatcherTimer(Action work, TimeSpan dueTime) { var timer = DispatcherQueue.CreateTimer(); timer.Interval = dueTime; @@ -73,4 +102,11 @@ protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTim timer.Start(); return Disposable.Create(timer, static t => t.Stop()); } + + /// Attempts a native dispatcher queue post. + /// The callback to enqueue. + /// Whether the dispatcher accepted the callback. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryEnqueue(DispatcherQueueHandler handler) => DispatcherQueue.TryEnqueue(Priority, handler); } diff --git a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs index d4b3ffed..bfa1ac2d 100644 --- a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs @@ -9,13 +9,14 @@ namespace ReactiveUI.Primitives.Concurrency; /// WinUI dispatcher queue sequencer that coalesces scheduled work through a . -/// Work runs on the dispatcher queue's thread, one batch per posted drain; scheduling from that thread queues -/// the item for the next drain rather than running it inline. Delayed work fires on a dispatcher queue timer, and an -/// item cancelled before its drain reaches it is skipped. +/// Callbacks run in posted dispatcher queue batches without inline reentrancy; cancellation suppresses unstarted work. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DispatcherQueueSequencer : ISequencer { + /// Optional callback for enqueueing native drain delegates. + private readonly Func? _tryEnqueue; + /// Coalescing dispatch engine. private DispatchSequencerState _state; @@ -45,6 +46,21 @@ public DispatcherQueueSequencer(DispatcherQueue dispatcherQueue, DispatcherQueue _state = new(this, Post, RunDrain, ScheduleDelayed); } + /// Initializes a new instance of the class. + /// Priority passed to the enqueue callback. + /// Attempts to enqueue each drain. + /// Schedules delayed work. + internal DispatcherQueueSequencer( + DispatcherQueuePriority priority, + Func tryEnqueue, + Action scheduleDelayed) + { + DispatcherQueue = null!; + Priority = priority; + _tryEnqueue = tryEnqueue; + _state = new(this, Post, RunDrain, scheduleDelayed); + } + /// Gets the dispatcher queue used to marshal work to the UI thread. public DispatcherQueue DispatcherQueue { get; } @@ -76,7 +92,7 @@ public DispatcherQueueSequencer(DispatcherQueue dispatcherQueue, DispatcherQueue private bool Post(Action drain) { _handler ??= drain.Invoke; - if (DispatcherQueue.TryEnqueue(Priority, _handler)) + if (_tryEnqueue is null ? TryEnqueue(_handler) : _tryEnqueue(Priority, _handler)) { return true; } @@ -84,6 +100,13 @@ private bool Post(Action drain) throw new InvalidOperationException("The dispatcher queue is no longer accepting work."); } + /// Attempts a native dispatcher queue post. + /// The callback to enqueue. + /// Whether the dispatcher accepted the callback. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryEnqueue(DispatcherQueueHandler handler) => DispatcherQueue.TryEnqueue(Priority, handler); + /// Runs delayed work on a dispatcher queue timer so it executes directly on the dispatcher thread. /// Work item to execute at the due time. /// Absolute monotonic timestamp at which to execute the item. diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs index aa360fc7..3e88c29a 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs @@ -8,13 +8,17 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; /// WPF dispatcher scheduler that coalesces scheduled work onto a dispatcher drain. -/// Work runs on the dispatcher's thread at and delayed work fires on a -/// , so disposing the returned subscription stops that timer as well as suppressing work -/// that has not started. +/// Callbacks run on the dispatcher thread at Priority; cancellation stops pending timers and suppresses unstarted actions. /// [System.Diagnostics.DebuggerDisplay("DispatcherSequencer: Dispatcher = {Dispatcher}, Priority = {Priority}")] public sealed class DispatcherSequencer : CoalescingDispatchScheduler { + /// Optional callback for posting ready work. + private readonly Func? _post; + + /// Optional callback for delayed work. + private readonly Func? _scheduleDelayed; + /// Initializes a new instance of the class. /// The dispatcher whose thread runs the scheduled work. /// is . @@ -28,9 +32,26 @@ public DispatcherSequencer(Dispatcher dispatcher) /// Dispatcher priority used for posted drains. /// is . public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) + : this(dispatcher, priority, null, null) + { + } + + /// Initializes a new instance of the class. + /// The dispatcher associated with this scheduler. + /// The dispatcher priority. + /// Posts ready work, or null to use the dispatcher. + /// Schedules delayed work, or null to use a dispatcher timer. + /// The dispatcher is null. + internal DispatcherSequencer( + Dispatcher dispatcher, + DispatcherPriority priority, + Func? post, + Func? scheduleDelayed) { Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); Priority = priority; + _post = post; + _scheduleDelayed = scheduleDelayed; } /// Gets the dispatcher whose thread runs the scheduled work. @@ -40,15 +61,29 @@ public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) public DispatcherPriority Priority { get; } /// - protected override bool Post(Action drain) + protected override bool Post(Action drain) => _post is null ? PostToDispatcher(drain) : _post(drain); + + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) => + _scheduleDelayed is null ? StartDispatcherTimer(work, dueTime) : _scheduleDelayed(work, dueTime); + + /// Posts a drain at the configured dispatcher priority. + /// The drain callback. + /// True once the dispatcher accepts the callback. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private bool PostToDispatcher(Action drain) { _ = Dispatcher.BeginInvoke(drain, Priority); return true; } - /// + /// Starts a cancellable dispatcher timer. + /// The callback to run. + /// The requested delay. + /// The timer cancellation handle. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - protected override IDisposable ScheduleOnDispatcher(Action work, TimeSpan dueTime) + private IDisposable StartDispatcherTimer(Action work, TimeSpan dueTime) { DispatcherTimer timer = new(Priority, Dispatcher) { Interval = dueTime }; timer.Tick += (_, _) => diff --git a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index b8680340..47f49db5 100644 --- a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -9,9 +9,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// WPF dispatcher sequencer that coalesces scheduled work onto a dispatcher drain. -/// Work runs on the dispatcher's thread at , one batch per posted drain; scheduling from -/// that thread queues the item for the next drain rather than running it inline. Delayed work fires on a -/// , and an item cancelled before its drain reaches it is skipped. +/// Callbacks run at Priority in posted dispatcher batches without inline reentrancy; cancellation suppresses unstarted work. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DispatcherSequencer : ISequencer @@ -32,10 +30,25 @@ public DispatcherSequencer(Dispatcher dispatcher) /// Dispatcher priority used for posted drains. /// is . public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) + : this(dispatcher, priority, null, null) + { + } + + /// Initializes a new instance of the class. + /// The dispatcher associated with this sequencer. + /// The dispatcher priority. + /// Posts ready work, or null to use the dispatcher. + /// Schedules delayed work, or null to use a dispatcher timer. + /// The dispatcher is null. + internal DispatcherSequencer( + Dispatcher dispatcher, + DispatcherPriority priority, + Func? post, + Action? scheduleDelayed) { Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); Priority = priority; - _state = new(this, Post, RunDrain, ScheduleDelayed); + _state = new(this, post ?? Post, RunDrain, scheduleDelayed ?? ScheduleDelayed); } /// Gets the dispatcher whose thread runs the scheduled work. @@ -65,6 +78,7 @@ public DispatcherSequencer(Dispatcher dispatcher, DispatcherPriority priority) /// Marshals the cached drain callback onto the dispatcher. /// The drain callback. /// , since the dispatcher always accepts the work. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private bool Post(Action drain) { _ = Dispatcher.BeginInvoke(drain, Priority); diff --git a/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs b/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs index 2fb26692..7cb29a38 100644 --- a/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs +++ b/src/ReactiveUI.Primitives/Advanced/DispatchSequencerState.cs @@ -9,11 +9,7 @@ namespace ReactiveUI.Primitives.Advanced; -/// -/// Coalescing engine shared by UI-thread sequencers. A sequencer holds one inline and supplies its platform -/// post delegate, an optional scheduleDelayed delegate and a cached drain callback; immediate work is -/// queued and drained one batch per post. -/// +/// Queues immediate work into posted batches and schedules delayed work through the supplied delegates. [SuppressMessage( "Performance", "SST1803:Make record struct readonly", diff --git a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs index 97957e2b..8e17ef32 100644 --- a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs @@ -105,7 +105,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) SequencerQueue? queue; - // Nothing is running on this thread, so the item runs inline rather than through the trampoline. + // Initial work executes before Schedule returns. if (!_running) { SetRunning(true); @@ -126,7 +126,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) throw; } - // Work the item scheduled recursively runs on the same trampoline before returning. + // Nested work finishes before the outer Schedule call returns. queue = GetQueue(); if (queue is not null) { @@ -150,7 +150,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) queue = GetQueue(); - // Work is running on this thread, so the item joins the trampoline queue, created on first use. + // Nested work waits for the current item to finish. if (queue is null) { queue = new(InitialQueueCapacity); diff --git a/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs b/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs index d0bc349b..3ad5a707 100644 --- a/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs +++ b/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs @@ -2,7 +2,6 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Disposables; @@ -141,20 +140,18 @@ public void Execute() return; } - DisposeIfRaced(disposable); + ReleaseCanceledResult(); } - /// Releases what the scheduled action returned when a concurrent latches the cancellation flag after the slot is claimed. - /// The disposable the scheduled action returned. - [ExcludeFromCodeCoverage] - private void DisposeIfRaced(IDisposable disposable) + /// Releases the published result if the work item is cancelled. + internal void ReleaseCanceledResult() { if (!IsDisposed) { return; } - disposable.Dispose(); + Interlocked.Exchange(ref _disposable, EmptyDisposable.Instance)?.Dispose(); } } diff --git a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs index 1e5f1e32..5393717f 100644 --- a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs @@ -17,9 +17,11 @@ public static partial class Sequencer public static ISequencer Default => TaskPoolSequencer.Default; /// Gets the shared wall-clock time used by real-time sequencers. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal static DateTimeOffset Now => TimeProvider.System.GetUtcNow(); /// Gets the current monotonic timestamp used by real-time sequencers. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal static long Timestamp => System.Diagnostics.Stopwatch.GetTimestamp(); /// Normalizes the specified value to a positive value. diff --git a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs index b56f57ff..91adaf2a 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs @@ -42,10 +42,10 @@ public static SynchronizationContextSequencer Current public SynchronizationContext Context { get; } /// Gets the scheduler's notion of current time. - public DateTimeOffset Now => Sequencer.Now; + public DateTimeOffset Now => _delaySequencer.Now; /// Gets the scheduler's monotonic timestamp. - public long Timestamp => Sequencer.Timestamp; + public long Timestamp => _delaySequencer.Timestamp; /// Gets the debugger display text. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] @@ -57,7 +57,7 @@ public void Schedule(IWorkItem item) { ArgumentExceptionHelper.ThrowIfNull(item); - Context.Post(static state => ExecutePosted((IWorkItem)state!), item); + Post(item); } /// @@ -76,7 +76,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) /// Executes the work item unless it has been cancelled. /// Work item to execute. - private static void ExecutePosted(IWorkItem item) + internal static void ExecutePosted(IWorkItem item) { if (Sequencer.IsCancelled(item)) { @@ -86,6 +86,12 @@ private static void ExecutePosted(IWorkItem item) item.Execute(); } + /// Posts a work item to the captured context. + /// The callback state. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private void Post(IWorkItem item) => Context.Post(static state => ExecutePosted((IWorkItem)state!), item); + /// Delayed post work item. /// Owning sequencer. /// Scheduled item. diff --git a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs index 49341509..faf6e216 100644 --- a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs @@ -45,10 +45,10 @@ internal TaskPoolSequencer(TaskFactory taskFactory, ISequencer delaySequencer) public Action? UnhandledExceptionHandler { get; set; } /// Gets the scheduler's notion of current time. - public DateTimeOffset Now => Sequencer.Now; + public DateTimeOffset Now => _delaySequencer.Now; /// Gets the scheduler's monotonic timestamp. - public long Timestamp => Sequencer.Timestamp; + public long Timestamp => _delaySequencer.Timestamp; /// Gets the debugger display text. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] @@ -62,12 +62,7 @@ public void Schedule(IWorkItem item) { ArgumentExceptionHelper.ThrowIfNull(item); - _ = _taskFactory.StartNew( - static state => ((DispatchState)state!).Run(), - new DispatchState(this, item), - _taskFactory.CancellationToken, - _taskFactory.CreationOptions, - _taskFactory.Scheduler ?? TaskScheduler.Default); + Queue(new(this, item)); } /// Schedules a work item to be executed through the task factory at a monotonic timestamp. @@ -89,7 +84,7 @@ public void Schedule(IWorkItem item, long dueTimestamp) /// Executes a work item and routes unhandled exceptions. /// Work item to execute. - private void Execute(IWorkItem item) + internal void Execute(IWorkItem item) { if (Sequencer.IsCancelled(item)) { @@ -113,6 +108,17 @@ private void Execute(IWorkItem item) } } + /// Queues a callback on the task factory's scheduler. + /// The callback state. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void Queue(DispatchState state) => + _ = _taskFactory.StartNew( + static value => ((DispatchState)value!).Run(), + state, + _taskFactory.CancellationToken, + _taskFactory.CreationOptions, + _taskFactory.Scheduler ?? TaskScheduler.Default); + /// Task factory dispatch state. /// Owning sequencer. /// Work item to execute. diff --git a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs index f7de0562..174d2677 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs @@ -48,11 +48,7 @@ public sealed class ThreadPoolSequencer : ISequencer, IDisposable "The timer is created disarmed, so nothing can call back into it until Schedule arms it after construction.")] internal ThreadPoolSequencer() { - _timer = new( - static state => ((ThreadPoolSequencer)state!).RunDue(), - this, - Timeout.InfiniteTimeSpan, - Timeout.InfiniteTimeSpan); + _timer = CreateTimer(this); _timestamp = static () => Sequencer.Timestamp; _queueImmediate = QueueOnThreadPool; _changeTimer = ChangeTimer; @@ -115,7 +111,6 @@ public void Schedule(IWorkItem item, long dueTimestamp) lock (_gate) { - // Queue under the disposal gate so accepted items are released during teardown. ObjectDisposedExceptionHelper.ThrowIf(IsDisposed, this); _queue.Enqueue(new(item, dueTimestamp)); @@ -123,14 +118,9 @@ public void Schedule(IWorkItem item, long dueTimestamp) } } - /// - /// Releases the delay timer this sequencer owns and cancels the delayed work queued behind it. Scheduling through - /// a disposed sequencer throws rather than accepting work that could never - /// become due. Work the thread pool has picked up runs to completion. - /// + /// Cancels pending delayed work and rejects further scheduling, allowing work already picked up by the thread pool to finish. public void Dispose() { - // The gate prevents rearming after disposal; timer disposal does not wait for callbacks. lock (_gate) { if (IsDisposed) @@ -171,6 +161,13 @@ internal void RunDue() private static void QueueOnThreadPool(WaitCallback callback, object state) => ThreadPool.UnsafeQueueUserWorkItem(callback, state); + /// Creates a disarmed timer that drains due work. + /// The sequencer receiving timer callbacks. + /// The disarmed timer. + [ExcludeFromCodeCoverage] + private static Timer CreateTimer(ThreadPoolSequencer owner) => + new(static state => ((ThreadPoolSequencer)state!).RunDue(), owner, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + /// Executes the work item unless it has been cancelled. /// Work item to execute. private static void ExecuteQueued(IWorkItem item) @@ -228,7 +225,6 @@ private void ArmTimerNoLock() { if (IsDisposed) { - // A callback unwinding after disposal must not rearm the timer. return; } diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs index fe630a61..e74267da 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerExtensions.cs @@ -60,7 +60,6 @@ public IDisposable ScheduleRelative(TRelative dueTime, Action action) ArgumentExceptionHelper.ThrowIfNull(action); - // A static lambda rather than a method group, so the compiler caches the delegate. return scheduler.ScheduleRelative(action, dueTime, static (_, a) => Invoke(a)); } diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs index b39a30f4..6f1cf312 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs @@ -6,11 +6,7 @@ namespace ReactiveUI.Primitives.Concurrency; -/// -/// Virtual time scheduler that runs scheduled work against a controllable clock. Per-clock arithmetic is supplied -/// as delegates at construction, so a single sealed type serves every / -/// pairing without an inheritance hierarchy. -/// +/// Runs scheduled work against a controllable clock using the supplied time arithmetic. /// Absolute time representation type. /// Relative time representation type. [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs index d95c085e..5e266bb8 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeState{TAbsolute,TRelative}.cs @@ -301,7 +301,6 @@ internal readonly IDisposable ScheduleAbsolute( { ArgumentExceptionHelper.ThrowIfNull(action); - // Capture the queue reference; a struct member cannot capture this in a closure. var queue = _queue; ScheduledItem si = new(dueTime, _comparer, self => diff --git a/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs index 6c594f3b..ad675efd 100644 --- a/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/WasmSequencer.cs @@ -8,10 +8,8 @@ namespace ReactiveUI.Primitives.Concurrency; -/// -/// Schedules batches on a single-threaded event loop without blocking or starting threads. -/// Delayed work uses the shared timer; immediate batches yield between event-loop turns. -/// +/// Schedules immediate batches and delayed work on a single-threaded event loop. +/// Immediate batches yield between event-loop turns. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class WasmSequencer : ISequencer, IDisposable @@ -42,11 +40,7 @@ public sealed class WasmSequencer : ISequencer, IDisposable "The timer is created disarmed, and _state is a struct held inline in this object, so neither reference escapes.")] internal WasmSequencer() { - _timer = new( - static state => ((WasmSequencer)state!).RunDrain(), - this, - Timeout.InfiniteTimeSpan, - Timeout.InfiniteTimeSpan); + _timer = CreateTimer(this); _postDrain = ArmDrainTimer; _scheduleDelayed = ThreadPoolSequencer.Instance.Schedule; _state = new(this, Post, RunDrain, ScheduleDelayed); @@ -98,13 +92,10 @@ public void Schedule(IWorkItem item, long dueTimestamp) ReleaseQueuedIfDisposed(); } - /// - /// Releases the drain timer and cancels queued work. Further scheduling throws. - /// Delayed work on the shared timer is released when due unless its caller cancels it first. - /// + /// Cancels queued immediate work and rejects further scheduling. + /// Delayed work is released when due unless its caller cancels it first. public void Dispose() { - // Timer arming and disposal share the gate; disposal does not wait for active callbacks. lock (_gate) { if (IsDisposed) @@ -127,6 +118,13 @@ internal void ScheduleReady(IWorkItem item) ReleaseQueuedIfDisposed(); } + /// Creates a disarmed timer that drains ready work. + /// The sequencer receiving timer callbacks. + /// The disarmed timer. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private static Timer CreateTimer(WasmSequencer owner) => + new(static state => ((WasmSequencer)state!).RunDrain(), owner, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + /// Arms the drain timer to fire on the next event-loop turn. /// The callback to post. /// when the timer accepted the change. @@ -134,7 +132,6 @@ private bool Post(Action drain) { lock (_gate) { - // Reject posts after disposal so the drain claim is released. return !IsDisposed && _postDrain(drain); } } @@ -193,7 +190,6 @@ public void Execute() return; } - // A disposal racing this enqueue is caught by ScheduleReady, which releases the queue it just joined. _owner.ScheduleReady(_item); } diff --git a/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs b/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs index 91ebc63d..11ea50d4 100644 --- a/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs +++ b/src/ReactiveUI.Primitives/Platforms/android/HandlerSequencer.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Concurrency; -/// -/// Android sequencer that coalesces scheduled work onto the thread backing a (typically the -/// main/UI looper). Immediate work is batched through a single cached drain, so the -/// per-post path allocates nothing; delayed work uses the native . -/// +/// Schedules immediate and delayed work on the Android handler thread. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class HandlerSequencer : ISequencer @@ -69,12 +65,20 @@ public HandlerSequencer(Handler handler) private bool Post(Action drain) { _drainRunnable ??= new Java.Lang.Runnable(drain); - return Handler.Post(_drainRunnable); + return PostToHandler(_drainRunnable); } + /// Posts the runnable through the native handler. + /// The runnable to post. + /// Whether the handler accepted the runnable. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool PostToHandler(Java.Lang.IRunnable runnable) => Handler.Post(runnable); + /// Runs delayed work through the handler's native delayed post. /// Work item to execute at the due time. /// Absolute monotonic timestamp at which to execute the item. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ScheduleDelayed(IWorkItem item, long dueTimestamp) => Handler.PostDelayed( diff --git a/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs b/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs index 4a9b5ef1..e712c83a 100644 --- a/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs +++ b/src/ReactiveUI.Primitives/Platforms/apple/NSRunloopSequencer.cs @@ -8,11 +8,7 @@ namespace ReactiveUI.Primitives.Concurrency; -/// -/// Apple sequencer that coalesces scheduled work onto the main (the UI thread on -/// iOS, tvOS, Mac Catalyst, and macOS). Immediate work is batched through a single cached -/// drain, so the per-post path allocates nothing; delayed work uses . -/// +/// Schedules immediate and delayed work on the Apple main dispatch queue. /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class NSRunloopSequencer : ISequencer @@ -56,19 +52,26 @@ public sealed class NSRunloopSequencer : ISequencer /// Runs delayed work through the main queue's native delayed dispatch. /// Work item to execute at the due time. /// Absolute monotonic timestamp at which to execute the item. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] private static void ScheduleDelayed(IWorkItem item, long dueTimestamp) { var nanoseconds = (long)DispatchSequencerState.DelayUntil(dueTimestamp).TotalMilliseconds * NanosecondsPerMillisecond; DispatchQueue.MainQueue.DispatchAfter(new(DispatchTime.Now, nanoseconds), () => DispatchSequencerState.RunIfActive(item)); } + /// Posts the block through the native main queue. + /// The callback block to post. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void DispatchOnMainQueue(DispatchBlock block) => DispatchQueue.MainQueue.DispatchAsync(block); + /// Marshals the cached drain callback onto the main dispatch queue. /// The drain callback. /// , since the main queue always accepts the work. private bool Post(Action drain) { _drainBlock ??= new DispatchBlock(drain); - DispatchQueue.MainQueue.DispatchAsync(_drainBlock); + DispatchOnMainQueue(_drainBlock); return true; } diff --git a/src/ReactiveUI.Primitives/RxVoid.cs b/src/ReactiveUI.Primitives/RxVoid.cs index 9762bbcb..1a6e6dd8 100644 --- a/src/ReactiveUI.Primitives/RxVoid.cs +++ b/src/ReactiveUI.Primitives/RxVoid.cs @@ -18,14 +18,14 @@ namespace ReactiveUI.Primitives; [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] private string DebuggerDisplay => ToString() ?? string.Empty; - /// Determines whether the two specified values are not equal. Because has a single value, this always returns false. + /// Returns false because all RxVoid values are equal. /// The first value to compare. /// The second value to compare. /// Because has a single value, this always returns false. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool operator !=(RxVoid first, RxVoid second) => false; - /// Determines whether the two specified values are equal. Because has a single value, this always returns true. + /// Returns true because all RxVoid values are equal. /// The first value to compare. /// The second value to compare. /// Because has a single value, this always returns true. diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BooleanSignalWitness.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BooleanSignalWitness.cs index 7d905bd2..5aca5a59 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BooleanSignalWitness.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/BooleanSignalWitness.cs @@ -17,7 +17,7 @@ internal sealed class BooleanSignalWitness : IObserver internal int ErrorCount { get; private set; } /// Called when a value is received. - /// The value. + /// The observed value. public void OnNext(bool value) => Value = value; /// Called when an error is observed. diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/IntR3ActionWitness.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/IntR3ActionWitness.cs index 76c787bf..7043483f 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/IntR3ActionWitness.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/IntR3ActionWitness.cs @@ -17,7 +17,7 @@ internal sealed class IntR3ActionWitness : Observer public IntR3ActionWitness(Action? onNext = null) => _onNext = onNext; /// Receives the next value. - /// The value. + /// The observed value. protected override void OnNextCore(int value) => _onNext?.Invoke(value); /// Receives an error. diff --git a/src/tests/ReactiveUI.Disposables.Tests/SingleReplaceableDisposableTests.cs b/src/tests/ReactiveUI.Disposables.Tests/SingleReplaceableDisposableTests.cs new file mode 100644 index 00000000..52a1b209 --- /dev/null +++ b/src/tests/ReactiveUI.Disposables.Tests/SingleReplaceableDisposableTests.cs @@ -0,0 +1,73 @@ +// 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.Primitives.Disposables; + +namespace ReactiveUI.Disposables.Tests; + +/// Tests replacement ownership when an observed slot changes. +public class SingleReplaceableDisposableTests +{ + /// The callback count after disposal and a later rejected assignment. + private const int DisposeAndAssignmentCallbacks = 2; + + /// A stale replacement leaves both the current and incoming resources untouched. + /// True when the original slot is empty. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TryCreate_StaleObservation_RetriesWithoutReleasingUnownedValues(bool initiallyEmpty) + { + CountingDisposable initial = new(); + CountingDisposable competing = new(); + CountingDisposable incoming = new(); + IDisposable? observed = initiallyEmpty ? null : initial; + SingleReplaceableDisposable slot = initiallyEmpty ? new() : new(initial); + slot.Create(competing); + + await Assert.That(slot.TryCreate(observed, incoming)).IsFalse(); + await Assert.That(initial.DisposeCount).IsEqualTo(initiallyEmpty ? 0 : 1); + await Assert.That(competing.DisposeCount).IsEqualTo(0); + await Assert.That(incoming.DisposeCount).IsEqualTo(0); + + await Assert.That(slot.TryCreate(competing, incoming)).IsTrue(); + await Assert.That(competing.DisposeCount).IsEqualTo(1); + await Assert.That(incoming.DisposeCount).IsEqualTo(0); + slot.Dispose(); + await Assert.That(incoming.DisposeCount).IsEqualTo(1); + } + + /// Disposal between observation and assignment rejects publication and releases the later assignment. + /// A task representing the asynchronous test. + [Test] + public async Task TryCreate_DisposedAfterObservation_DoesNotPublishIncomingValue() + { + CountingDisposable initial = new(); + CountingDisposable incoming = new(); + var callbacks = 0; + SingleReplaceableDisposable slot = new(initial, () => callbacks++); + slot.Dispose(); + + await Assert.That(slot.TryCreate(initial, incoming)).IsFalse(); + await Assert.That(initial.DisposeCount).IsEqualTo(1); + await Assert.That(incoming.DisposeCount).IsEqualTo(0); + + slot.Create(incoming); + + await Assert.That(slot.IsDisposed).IsTrue(); + await Assert.That(incoming.DisposeCount).IsEqualTo(1); + await Assert.That(callbacks).IsEqualTo(DisposeAndAssignmentCallbacks); + } + + /// Counts every disposal invocation. + private sealed class CountingDisposable : IDisposable + { + /// Gets the number of disposal invocations. + public int DisposeCount { get; private set; } + + /// + public void Dispose() => DisposeCount++; + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs index 7c5db18a..c7c2c0a1 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs @@ -82,7 +82,7 @@ public async Task AsyncContextRenamedMembersExposeDefaultAndSequencerSchedulerPa await Assert.That(AsyncContext.Default.UsesDefaultSequencer).IsTrue(); await Assert.That(sequencerContext.UsesDefaultSequencer).IsFalse(); await Assert.That(AsyncContext.From(new SynchronizationContext()).UsesDefaultSequencer).IsFalse(); - await Assert.That(AsyncContext.From(CustomTaskScheduler.Instance).UsesDefaultSequencer).IsFalse(); + await Assert.That(AsyncContext.From(new CustomTaskScheduler()).UsesDefaultSequencer).IsFalse(); await Assert.That(syncSequencerContext.SynchronizationContext).IsSameReferenceAs(syncSequencer); await Assert.That(sequencerContext.IsSameAsCurrentAsyncContext()).IsFalse(); await Assert.That(scheduler.Sequencer).IsSameReferenceAs(sequencer); @@ -121,7 +121,7 @@ public async Task AsyncContextSwitcherBranchesCoverCustomSchedulersAndCancellati canceledAwaitable.OnCompleted(() => cancellationCallbacks++); await Assert.That(cancellationCallbacks).IsEqualTo(1); TaskCompletionSource scheduled = new(TaskCreationOptions.RunContinuationsAsynchronously); - var schedulerAwaitable = AsyncContext.From(CustomTaskScheduler.Instance) + var schedulerAwaitable = AsyncContext.From(new CustomTaskScheduler()) .SwitchContextAsync(true, CancellationToken.None); schedulerAwaitable.OnCompleted(scheduled.SetResult); await scheduled.Task.ConfigureAwait(false); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CancellationStopCoordinatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CancellationStopCoordinatorTests.cs new file mode 100644 index 00000000..38c3b0df --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CancellationStopCoordinatorTests.cs @@ -0,0 +1,50 @@ +// 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.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests cancellation completion through the serialized notification path. +public sealed class CancellationStopCoordinatorTests +{ + /// The recorded value notification. + private const string ValueNotification = "value"; + + /// The recorded start of completion. + private const string CompletionEntered = "completion entered"; + + /// Completion waits for the downstream completion callback. + /// The test operation. + [Test] + public async Task CompleteFromCancellationAsync_WhenCompletionIsPending_ThenWaitsForObserver() + { + var source = Signal.Create(); + TaskCompletionSource resume = new(TaskCreationOptions.RunContinuationsAsynchronously); + List calls = []; + CallbackWitnessAsync observer = new( + (_, _) => + { + calls.Add(ValueNotification); + return default; + }, + null, + async result => + { + calls.Add(result.IsSuccess ? CompletionEntered : "failed"); + await resume.Task; + calls.Add("completion finished"); + }); + SignalAsyncExtensions.CancellationStopSignal signal = new(source.Values, CancellationToken.None); + await using SignalAsyncExtensions.CancellationStopSignal.CancellationStopCoordinator coordinator = new(signal, observer); + await coordinator.SubscribeSourcesAsync(CancellationToken.None); + await source.OnNextAsync(1, CancellationToken.None); + var completion = coordinator.CompleteFromCancellationAsync().AsTask(); + await Assert.That(completion.IsCompleted).IsFalse(); + await Assert.That(calls).IsCollectionEqualTo([ValueNotification, CompletionEntered]); + resume.SetResult(); + await completion; + await Assert.That(calls).IsCollectionEqualTo([ValueNotification, CompletionEntered, "completion finished"]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ContextSwitchSignalAsyncTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ContextSwitchSignalAsyncTests.cs new file mode 100644 index 00000000..abc2984b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ContextSwitchSignalAsyncTests.cs @@ -0,0 +1,263 @@ +// 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.Runtime.CompilerServices; +using ReactiveUI.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests notification context switches through explicitly delivered callbacks. +public class ContextSwitchSignalAsyncTests +{ + /// The value delivered by the source. + private const int Sentinel = 7; + + /// Default context overloads construct context-switching signals without dispatching. + /// The test operation. + [Test] + public async Task DefaultContextOverloadsWrapTheSource() + { + var source = SignalAsync.Return(Sentinel); + await Assert.That(source.WitnessOn(AsyncContext.Default, true)).IsTypeOf>(); + await Assert.That(source.WitnessOn(AsyncContext.Default)).IsTypeOf>(); + await Assert.That(source.WitnessOn(TaskScheduler.Default)).IsTypeOf>(); + await Assert.That(source.WitnessOn(new SynchronizationContext())).IsTypeOf>(); + } + + /// The async context overload waits for a callback before delivering a value. + /// Whether to force a context switch. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task AsyncContextOverloadWaitsForDelivery(bool forceYielding) + { + ManualContext context = new(); + var source = Signal.Create(); + RecordingWitness observer = new(); + await using var subscription = await source.Values.WitnessOn(AsyncContext.From(context), forceYielding) + .SubscribeAsync(observer, CancellationToken.None); + var pending = source.OnNextAsync(Sentinel, CancellationToken.None); + await Assert.That(observer.Value).IsNull(); + await Assert.That(context.PendingCount).IsEqualTo(1); + context.RunNext(); + await pending; + await Assert.That(observer.Value).IsEqualTo(Sentinel); + } + + /// Synchronization context overloads retain notifications until their callback runs. + /// Whether to use the forced-yield overload. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task SynchronizationContextOverloadsWaitForDelivery(bool forceYielding) + { + ManualContext context = new(); + var source = Signal.Create(); + RecordingWitness observer = new(); + var observed = forceYielding ? source.Values.WitnessOn(context, true) : source.Values.WitnessOn(context); + await using var subscription = await observed.SubscribeAsync(observer, CancellationToken.None); + var pending = source.OnNextAsync(Sentinel, CancellationToken.None); + await Assert.That(observer.Value).IsNull(); + context.RunNext(); + await pending; + await Assert.That(observer.Value).IsEqualTo(Sentinel); + } + + /// Task scheduler overloads wait for explicit scheduler execution. + /// Whether to use the forced-yield overload. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TaskSchedulerOverloadsWaitForDelivery(bool forceYielding) + { + ManualTaskScheduler scheduler = new(); + var source = Signal.Create(); + RecordingWitness observer = new(); + var observed = forceYielding ? source.Values.WitnessOn(scheduler, true) : source.Values.WitnessOn(scheduler); + await using var subscription = await observed.SubscribeAsync(observer, CancellationToken.None); + var pending = source.OnNextAsync(Sentinel, CancellationToken.None); + await Assert.That(observer.Value).IsNull(); + await Assert.That(scheduler.PendingCount).IsEqualTo(1); + scheduler.RunNext(); + await pending; + await Assert.That(observer.Value).IsEqualTo(Sentinel); + } + + /// Failure completion is delivered only after the context callback runs. + /// Whether to force a context switch. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task FailureCompletionWaitsForDelivery(bool forceYielding) + { + ManualContext context = new(); + var source = Signal.Create(); + RecordingWitness observer = new(); + InvalidOperationException expected = new(); + await using var subscription = await source.Values.WitnessOn(context, forceYielding) + .SubscribeAsync(observer, CancellationToken.None); + var pending = source.OnCompletedAsync(Result.Failure(expected)); + await Assert.That(observer.Completion).IsNull(); + context.RunNext(); + await pending; + await Assert.That(observer.Completion!.Value.Exception).IsSameReferenceAs(expected); + } + + /// Successful completion waits for the context callback. + /// Whether to force a context switch. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task SuccessfulCompletionWaitsForDelivery(bool forceYielding) + { + ManualContext context = new(); + var source = Signal.Create(); + RecordingWitness observer = new(); + await using var subscription = await source.Values.WitnessOn(context, forceYielding) + .SubscribeAsync(observer, CancellationToken.None); + var pending = source.OnCompletedAsync(Result.Success); + await Assert.That(observer.Completion).IsNull(); + context.RunNext(); + await pending; + await Assert.That(observer.Completion!.Value.IsSuccess).IsTrue(); + } + + /// A resumable error waits for the context callback and preserves its identity. + /// Whether to force a context switch. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ResumableErrorWaitsForDelivery(bool forceYielding) + { + ManualContext context = new(); + var source = Signal.Create(); + RecordingWitness observer = new(); + InvalidOperationException expected = new(); + await using var subscription = await source.Values.WitnessOn(context, forceYielding) + .SubscribeAsync(observer, CancellationToken.None); + var pending = source.OnErrorResumeAsync(expected, CancellationToken.None); + await Assert.That(observer.Error).IsNull(); + context.RunNext(); + await pending; + await Assert.That(observer.Error).IsSameReferenceAs(expected); + } + + /// Direct forwarding waits for each manually delivered continuation. + /// The test operation. + [Test] + public async Task DirectForwardingWaitsForEachDelivery() + { + ManualContext context = new(); + RecordingWitness observer = new(); + await using ContextSwitchSignalAsync.ContextSwitchWitness witness = new(observer, AsyncContext.From(context), true); + var value = witness.ForwardAfterContextSwitchAsync(Sentinel, CancellationToken.None); + await Assert.That(observer.Value).IsNull(); + context.RunNext(); + await value; + await Assert.That(observer.Value).IsEqualTo(Sentinel); + InvalidOperationException expected = new(); + var error = witness.ForwardErrorAfterContextSwitchAsync(expected, CancellationToken.None); + await Assert.That(observer.Error).IsNull(); + context.RunNext(); + await error; + await Assert.That(observer.Error).IsSameReferenceAs(expected); + var completion = witness.ForwardCompletionAfterContextSwitchAsync(Result.Success); + await Assert.That(observer.Completion).IsNull(); + context.RunNext(); + await completion; + await Assert.That(observer.Completion!.Value.IsSuccess).IsTrue(); + } + + /// Subscribing directly wraps every notification kind in the selected context. + /// Whether to force a context switch. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task SubscribeWrapsAllNotificationKinds(bool forceYielding) + { + ManualContext context = new(); + var source = Signal.Create(); + RecordingWitness observer = new(); + IObservableAsync signal = new ContextSwitchSignalAsync(source.Values, AsyncContext.From(context), forceYielding); + await using var subscription = await signal.SubscribeAsync(observer, CancellationToken.None); + var value = source.OnNextAsync(Sentinel, CancellationToken.None); + context.RunNext(); + await value; + await Assert.That(observer.Value).IsEqualTo(Sentinel); + InvalidOperationException expected = new(); + var error = source.OnErrorResumeAsync(expected, CancellationToken.None); + context.RunNext(); + await error; + await Assert.That(observer.Error).IsSameReferenceAs(expected); + var completion = source.OnCompletedAsync(Result.Success); + context.RunNext(); + await completion; + await Assert.That(observer.Completion!.Value.IsSuccess).IsTrue(); + } + + /// Retains context continuations until explicitly invoked. + private sealed class ManualContext : SynchronizationContext + { + /// Pending callbacks and their state. + private readonly Queue<(SendOrPostCallback Callback, object? State)> _callbacks = new(); + + /// Gets the number of pending callbacks. + public int PendingCount => _callbacks.Count; + + /// + public override void Post(SendOrPostCallback d, object? state) => _callbacks.Enqueue((d, state)); + + /// Runs one retained continuation. + public void RunNext() + { + var pending = _callbacks.Dequeue(); + pending.Callback(pending.State); + } + } + + /// Records notifications without scheduling continuations. + private sealed class RecordingWitness : IObserverAsync + { + /// Gets the last value. + public int? Value { get; private set; } + + /// Gets the last resumable error. + public Exception? Error { get; private set; } + + /// Gets the completion result. + public Result? Completion { get; private set; } + + /// + public ValueTask OnNextAsync(int value, CancellationToken cancellationToken) + { + Value = value; + return default; + } + + /// + public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) + { + Error = error; + return default; + } + + /// + public ValueTask OnCompletedAsync(Result result) + { + Completion = result; + return default; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ValueTask DisposeAsync() => default; + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs index 29cbcefa..63ba017d 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/CustomTaskScheduler.cs @@ -2,37 +2,64 @@ // 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.Runtime.CompilerServices; - namespace ReactiveUI.Primitives.Async.Tests; -/// A scheduler distinct from that runs each queued task on the thread pool. +/// Runs tasks on the calling thread, queuing nested work until the current task returns. internal sealed class CustomTaskScheduler : TaskScheduler { - /// Singleton instance. - internal static readonly CustomTaskScheduler Instance = new(); + /// Serializes access to pending work. + private readonly Lock _gate = new(); + + /// Tasks waiting for the current task to return. + private readonly Queue _tasks = new(); + + /// Whether a caller is executing pending work. + private bool _isDraining; /// Initializes a new instance of the class. - private CustomTaskScheduler() + internal CustomTaskScheduler() { } /// - protected override void QueueTask(Task task) => - ThreadPool.UnsafeQueueUserWorkItem( - static state => IgnoredResult.Of(state.Scheduler.ExecuteQueued(state.Work)), - (Scheduler: this, Work: task), - false); + protected override void QueueTask(Task task) + { + lock (_gate) + { + _tasks.Enqueue(task); + if (_isDraining) + { + return; + } + + _isDraining = true; + } + + while (true) + { + Task? next; + lock (_gate) + { + if (!_tasks.TryDequeue(out next)) + { + _isDraining = false; + return; + } + } + + IgnoredResult.Of(TryExecuteTask(next)); + } + } /// protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; /// - protected override IEnumerable? GetScheduledTasks() => null; - - /// Runs a queued task on the pool thread that picked it up. - /// The queued task. - /// when the task was executed. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool ExecuteQueued(Task task) => TryExecuteTask(task); + protected override IEnumerable? GetScheduledTasks() + { + lock (_gate) + { + return _tasks.ToArray(); + } + } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs index 5713d6d1..33a357e6 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ErrorHandlingOperatorTests.cs @@ -132,7 +132,7 @@ public async Task WhenRetryOnTransientError_ThenSucceedsAfterRetry() await obs.OnNextAsync(SuccessValue, ct); await obs.OnCompletedAsync(Result.Success); }, - CustomTaskScheduler.Instance); + new CustomTaskScheduler()); var result = await source.Retry(RetryCount).ToListAsync(); await Assert.That(result).IsCollectionEqualTo([SuccessValue]); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); @@ -226,7 +226,7 @@ public async Task WhenRetryWithCountZero_ThenPropagatesErrorImmediately() attempt++; await obs.OnCompletedAsync(Result.Failure(new InvalidOperationException($"attempt {attempt}"))); }, - CustomTaskScheduler.Instance); + new CustomTaskScheduler()); await using var sub = await source.Retry(0).SubscribeAsync(static (_, _) => default, null, result => { _ = completed.TrySetResult(result); @@ -253,7 +253,7 @@ public async Task WhenRetryCountExhausted_ThenPropagatesLastError() attempt++; await obs.OnCompletedAsync(Result.Failure(new InvalidOperationException($"attempt {attempt}"))); }, - CustomTaskScheduler.Instance); + new CustomTaskScheduler()); await using var sub = await source.Retry(RetryCount).SubscribeAsync(static (_, _) => default, null, result => { _ = completed.TrySetResult(result); @@ -278,7 +278,7 @@ public async Task WhenRetryWithCountOne_ThenRetriesOnceAndPropagates() attempt++; await obs.OnCompletedAsync(Result.Failure(new InvalidOperationException($"attempt {attempt}"))); }, - CustomTaskScheduler.Instance); + new CustomTaskScheduler()); await using var sub = await source.Retry(1).SubscribeAsync(static (_, _) => default, null, result => { _ = completed.TrySetResult(result); @@ -480,7 +480,7 @@ public async Task WhenRetryParameterless_ThenRetriesUntilSuccess() await obs.OnNextAsync(SuccessValue, ct); await obs.OnCompletedAsync(Result.Success); }, - CustomTaskScheduler.Instance); + new CustomTaskScheduler()); var result = await source.Retry().ToListAsync(); await Assert.That(result).IsCollectionEqualTo([SuccessValue]); await Assert.That(attempt).IsEqualTo(ExpectedAttempts); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs index 1a5f4665..add6f9f1 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/FactorySignalTests.cs @@ -247,7 +247,7 @@ static async (observer, ct) => await observer.OnNextAsync(SentinelValue, ct); await observer.OnCompletedAsync(Result.Success); }, - CustomTaskScheduler.Instance); + new CustomTaskScheduler()); var result = await source.ToListAsync(); await Assert.That(result).IsCollectionEqualTo([SentinelValue]); } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs new file mode 100644 index 00000000..0db42ad6 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs @@ -0,0 +1,72 @@ +// 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.Primitives.Async.Tests; + +/// Tests interval cancellation and notification ordering with a controlled clock. +public sealed class IntervalSubscriptionTests +{ + /// Cancellation while waiting prevents the pending tick from being delivered. + /// The test operation. + [Test] + public async Task ExecuteAsync_WhenCanceledBeforeTheTimer_ThenDoesNotEmit() + { + ManualTimeProvider time = new(); + using CancellationTokenSource cancellation = new(); + List ticks = []; + List completions = []; + CallbackWitnessAsync observer = new( + (tick, _) => + { + ticks.Add(tick); + return default; + }, + null, + result => + { + completions.Add(result); + return default; + }); + await using IntervalSubscription subscription = new(observer, TimeSpan.FromSeconds(1), time); + var running = subscription.ExecuteAsync(cancellation.Token).AsTask(); + var timer = await time.NextTimerAsync(); + await cancellation.CancelAsync(); + await running; + timer.Fire(); + await Assert.That(ticks).IsEmpty(); + await Assert.That(completions).Count().IsEqualTo(1); + await Assert.That(completions[0].Exception).IsTypeOf(); + } + + /// The next delay starts only after the current notification finishes. + /// The test operation. + [Test] + public async Task ExecuteAsync_WhenNotificationIsPending_ThenDoesNotScheduleAnotherTick() + { + ManualTimeProvider time = new(); + using CancellationTokenSource cancellation = new(); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource resume = new(TaskCreationOptions.RunContinuationsAsynchronously); + List ticks = []; + CallbackWitnessAsync observer = new( + async (tick, _) => + { + ticks.Add(tick); + entered.SetResult(); + await resume.Task; + }, + null, + null); + await using IntervalSubscription subscription = new(observer, TimeSpan.FromSeconds(1), time); + var running = subscription.ExecuteAsync(cancellation.Token).AsTask(); + await time.FireNextAsync(); + await entered.Task; + await Assert.That(time.PendingTimerCount).IsEqualTo(0); + await cancellation.CancelAsync(); + resume.SetResult(); + await running; + await Assert.That(ticks).IsCollectionEqualTo([1L]); + await Assert.That(time.PendingTimerCount).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ManualTaskScheduler.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ManualTaskScheduler.cs new file mode 100644 index 00000000..5f4a0ab7 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ManualTaskScheduler.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. + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Retains scheduled tasks until explicitly executed. +internal sealed class ManualTaskScheduler : TaskScheduler +{ + /// Tasks waiting for execution. + private readonly Queue _tasks = new(); + + /// Gets the number of tasks waiting for execution. + internal int PendingCount => _tasks.Count; + + /// Executes the oldest queued task. + internal void RunNext() => _ = TryExecuteTask(_tasks.Dequeue()); + + /// + protected override void QueueTask(Task task) => _tasks.Enqueue(task); + + /// + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + + /// + protected override IEnumerable GetScheduledTasks() => _tasks; +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs deleted file mode 100644 index 0eb0e4bf..00000000 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnAsyncSignalTests.cs +++ /dev/null @@ -1,322 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System.Runtime.CompilerServices; -using ReactiveUI.Primitives.Async.Signals; - -namespace ReactiveUI.Primitives.Async.Tests; - -/// 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 ObserveOnAsyncSignalTests -{ - /// 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 SignalAsync.Return(Sentinel) - .WitnessOn(AsyncContext.Default, 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() - { - InvalidOperationException expected = new("forced"); - InvalidOperationException? caught = null; - - try - { - await SignalAsync.Throw(expected) - .WitnessOn(AsyncContext.Default, 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 SignalAsync.Empty() - .WitnessOn(AsyncContext.Default, 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 SignalAsync.Return(Sentinel) - .WitnessOn(ctx, true) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(Sentinel); - } - - /// Verifies the default SynchronizationContext overload forwards through the non-forced wrapper. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenSyncContextDefaultOverload_ThenEmits() - { - var ctx = SynchronizationContext.Current ?? new SynchronizationContext(); - - var result = await SignalAsync.Return(Sentinel) - .WitnessOn(ctx) - .FirstAsync(); - - await Assert.That(result).IsEqualTo(Sentinel); - } - - /// Verifies the default overload forwards through the non-forced wrapper. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenTaskSchedulerDefaultOverload_ThenEmits() - { - var result = await SignalAsync.Return(Sentinel) - .WitnessOn(TaskScheduler.Default) - .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() - { - InvalidOperationException expected = new("differing-context-error"); - InvalidOperationException? caught = null; - SynchronizationContext customCtx = new(); - - try - { - await SignalAsync.Throw(expected) - .WitnessOn(customCtx, 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() - { - SynchronizationContext customCtx = new(); - - var result = await SignalAsync.Empty() - .WitnessOn(customCtx, false) - .ToListAsync(); - - await Assert.That(result).IsEmpty(); - } - - /// Exercises ContextSwitchObserver.OnErrorResumeAsyncCore's slow-path branch — - /// when forceYielding == true, the resumable-error path returns - /// ForwardErrorAfterContextSwitchAsync(...) rather than the fast-path direct forward. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenForceYieldingSourceEmitsResumableError_ThenSlowPathForwards() - { - var signal = Signal.Create(); - Exception? caught = null; - TaskCompletionSource errorTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await signal.Values - .WitnessOn(AsyncContext.Default, true) - .SubscribeAsync( - static (_, _) => default, - (ex, _) => - { - caught = ex; - IgnoredResult.Of(errorTcs.TrySetResult()); - return default; - }); - - InvalidOperationException expected = new("observeon-resume"); - await signal.OnErrorResumeAsync(expected, CancellationToken.None); - - await errorTcs.Task; - await Assert.That(caught).IsSameReferenceAs(expected); - } - - /// Verifies ContextSwitchObserver.ForwardAfterContextSwitchAsync 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 WhenForwardAfterContextSwitchAsyncInvokedDirectly_ThenValueForwarded() - { - TaskCompletionSource captured = new(TaskCreationOptions.RunContinuationsAsynchronously); - CapturingAsyncWitness downstream = new(captured); - ContextSwitchSignalAsync.ContextSwitchWitness sut = new(downstream, AsyncContext.Default, true); - - await sut.ForwardAfterContextSwitchAsync(Sentinel, CancellationToken.None); - - var received = await captured.Task; - await Assert.That(received).IsEqualTo(Sentinel); - } - - /// Verifies ContextSwitchObserver.ForwardErrorAfterContextSwitchAsync by calling it directly. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenForwardErrorAfterContextSwitchAsyncInvokedDirectly_ThenErrorForwarded() - { - TaskCompletionSource captured = new(TaskCreationOptions.RunContinuationsAsynchronously); - CapturingAsyncWitness downstream = new(captured); - ContextSwitchSignalAsync.ContextSwitchWitness sut = new(downstream, AsyncContext.Default, true); - InvalidOperationException expected = new("slow-path-error"); - - await sut.ForwardErrorAfterContextSwitchAsync(expected, CancellationToken.None); - - var received = await captured.Task; - await Assert.That(received).IsSameReferenceAs(expected); - } - - /// Verifies ContextSwitchObserver.ForwardCompletionAfterContextSwitchAsync by calling it directly. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenForwardCompletionAfterContextSwitchAsyncInvokedDirectly_ThenCompletionForwarded() - { - TaskCompletionSource captured = new(TaskCreationOptions.RunContinuationsAsynchronously); - CapturingAsyncWitness downstream = new(captured); - ContextSwitchSignalAsync.ContextSwitchWitness sut = new(downstream, AsyncContext.Default, true); - - await sut.ForwardCompletionAfterContextSwitchAsync(Result.Success); - - var result = await captured.Task; - await Assert.That(result.IsSuccess).IsTrue(); - } - - /// Verifies that subscribing to a directly — rather than - /// through WitnessOn, which builds a WitnessOnSignal instead — wraps the downstream observer in - /// the context-switching witness and forwards source values through it. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenContextSwitchSignalSubscribed_ThenValueForwarded() - { - var signal = Signal.Create(); - ContextSwitchSignalAsync sut = new(signal.Values, AsyncContext.Default, true); - TaskCompletionSource received = new(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await sut.SubscribeAsync((x, _) => - { - IgnoredResult.Of(received.TrySetResult(x)); - return default; - }); - - await signal.OnNextAsync(Sentinel, CancellationToken.None); - - var value = await received.Task; - await Assert.That(value).IsEqualTo(Sentinel); - } - - /// Verifies that a resumable error raised by the source of a - /// reaches the downstream observer through the witness's OnErrorResumeAsyncCore context-switch path. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenContextSwitchSignalSourceEmitsResumableError_ThenErrorForwarded() - { - var signal = Signal.Create(); - ContextSwitchSignalAsync sut = new(signal.Values, AsyncContext.Default, true); - Exception? caught = null; - TaskCompletionSource errorTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await sut.SubscribeAsync( - static (_, _) => default, - (ex, _) => - { - caught = ex; - IgnoredResult.Of(errorTcs.TrySetResult()); - return default; - }); - - InvalidOperationException expected = new("context-switch-resume"); - await signal.OnErrorResumeAsync(expected, CancellationToken.None); - - await errorTcs.Task; - await Assert.That(caught).IsSameReferenceAs(expected); - } - - /// Test observer that captures the first OnNextAsync value, the first - /// OnErrorResumeAsync exception, and the OnCompletedAsync result via TCSes. - /// The element type. - private sealed class CapturingAsyncWitness : 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 CapturingAsyncWitness(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 CapturingAsyncWitness(TaskCompletionSource onError) => _onError = onError; - - /// Initializes a new instance of the class with an OnCompleted capture target. - /// The TCS that receives the OnCompletedAsync result. - public CapturingAsyncWitness(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; - } - - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ValueTask DisposeAsync() => default; - } -} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnIfOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnIfOperatorTests.cs index 980c2f52..bef1d082 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnIfOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnIfOperatorTests.cs @@ -4,11 +4,7 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// Tests for the forced-yield overloads of the ObserveOnIf parity helper — the ones that take an -/// explicit forceYielding flag. A false condition must hand the source back untouched; a true -/// condition must wrap it in a context-switching sequence. -/// +/// Tests conditional context-switching wrapper selection. public class ObserveOnIfOperatorTests { /// The value the sources emit. @@ -27,18 +23,17 @@ public async Task WhenObserveOnIfAsyncContextConditionFalseWithForcedYielding_Th await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); } - /// Verifies that a true condition makes the forced-yield overload wrap - /// the source in a context-switching sequence that still forwards the value. + /// A true condition selects the context-switching wrapper. /// A representing the asynchronous test operation. [Test] - public async Task WhenObserveOnIfAsyncContextConditionTrueWithForcedYielding_ThenWrapsAndForwards() + public async Task WhenObserveOnIfAsyncContextConditionTrueWithForcedYielding_ThenWrapsSource() { var source = SignalAsync.Return(Sentinel); var observed = source.ObserveOnIf(true, AsyncContext.Default, true); await Assert.That(ReferenceEquals(observed, source)).IsFalse(); - await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); + await Assert.That(observed).IsTypeOf>(); } /// Verifies that a false condition makes the forced-yield overload a no-op. @@ -54,17 +49,16 @@ public async Task WhenObserveOnIfTaskSchedulerConditionFalseWithForcedYielding_T await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); } - /// Verifies that a true condition makes the forced-yield overload wrap - /// the source in a context-switching sequence that still forwards the value. + /// A true condition selects the scheduler context wrapper. /// A representing the asynchronous test operation. [Test] - public async Task WhenObserveOnIfTaskSchedulerConditionTrueWithForcedYielding_ThenWrapsAndForwards() + public async Task WhenObserveOnIfTaskSchedulerConditionTrueWithForcedYielding_ThenWrapsSource() { var source = SignalAsync.Return(Sentinel); var observed = source.ObserveOnIf(true, TaskScheduler.Default, true); await Assert.That(ReferenceEquals(observed, source)).IsFalse(); - await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); + await Assert.That(observed).IsTypeOf>(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnSafeOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnSafeOperatorTests.cs index 283b1bfc..ec834a4d 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnSafeOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ObserveOnSafeOperatorTests.cs @@ -4,19 +4,13 @@ namespace ReactiveUI.Primitives.Async.Tests; -/// -/// Tests for the forced-yield overloads of the ObserveOnSafe parity helper — the ones that take an -/// explicit forceYielding flag alongside an optional or -/// . A target must hand back the source untouched; -/// a supplied target must wrap it. -/// +/// Tests optional context targets and forced-yield wrapper selection. public class ObserveOnSafeOperatorTests { /// The value the sources emit. private const int Sentinel = 42; - /// Verifies that a null makes the forced-yield overload a no-op: - /// the very same sequence instance comes back rather than a context-switching wrapper. + /// A null context preserves the source instance. /// A representing the asynchronous test operation. [Test] public async Task WhenObserveOnSafeAsyncContextIsNullWithForcedYielding_ThenReturnsSourceUnchanged() @@ -29,18 +23,17 @@ public async Task WhenObserveOnSafeAsyncContextIsNullWithForcedYielding_ThenRetu await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); } - /// Verifies that a supplied makes the forced-yield overload wrap the - /// source in a context-switching sequence that still forwards the value. + /// A supplied context selects the context-switching wrapper. /// A representing the asynchronous test operation. [Test] - public async Task WhenObserveOnSafeAsyncContextSuppliedWithForcedYielding_ThenWrapsAndForwards() + public async Task WhenObserveOnSafeAsyncContextSuppliedWithForcedYielding_ThenWrapsSource() { var source = SignalAsync.Return(Sentinel); var observed = source.ObserveOnSafe(AsyncContext.Default, true); await Assert.That(ReferenceEquals(observed, source)).IsFalse(); - await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); + await Assert.That(observed).IsTypeOf>(); } /// Verifies that a null makes the forced-yield overload a no-op. @@ -56,17 +49,16 @@ public async Task WhenObserveOnSafeTaskSchedulerIsNullWithForcedYielding_ThenRet await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); } - /// Verifies that a supplied makes the forced-yield overload wrap the - /// source in a context-switching sequence that still forwards the value. + /// A supplied scheduler selects the context-switching wrapper. /// A representing the asynchronous test operation. [Test] - public async Task WhenObserveOnSafeTaskSchedulerSuppliedWithForcedYielding_ThenWrapsAndForwards() + public async Task WhenObserveOnSafeTaskSchedulerSuppliedWithForcedYielding_ThenWrapsSource() { var source = SignalAsync.Return(Sentinel); var observed = source.ObserveOnSafe(TaskScheduler.Default, true); await Assert.That(ReferenceEquals(observed, source)).IsFalse(); - await Assert.That(await observed.FirstAsync()).IsEqualTo(Sentinel); + await Assert.That(observed).IsTypeOf>(); } } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/StartSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/StartSubscriptionTests.cs new file mode 100644 index 00000000..4129c029 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/StartSubscriptionTests.cs @@ -0,0 +1,105 @@ +// 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.Primitives.Async.Tests; + +/// Tests ordered execution and failure delivery for start subscriptions. +public sealed class StartSubscriptionTests +{ + /// Both start paths deliver the action result before successful completion. + /// Whether to queue the action. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Action_ThenValueAndCompletionAreDeliveredInOrder(bool schedule) + { + ManualTaskScheduler scheduler = new(); + List calls = []; + CallbackWitnessAsync observer = new( + (_, _) => + { + calls.Add("value"); + return default; + }, + null, + result => + { + calls.Add(result.IsSuccess ? "completed" : "failed"); + return default; + }); + await using StartSubscription subscription = new(observer, () => calls.Add("action"), schedule ? scheduler : null); + var running = subscription.ExecuteAsync(CancellationToken.None).AsTask(); + if (schedule) + { + await Assert.That(calls).IsEmpty(); + scheduler.RunNext(); + } + + await running; + await Assert.That(calls).IsCollectionEqualTo(["action", "value", "completed"]); + } + + /// A function failure completes the observer without emitting a value. + /// Whether to queue the function. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Function_WhenItThrows_ThenCompletesWithTheSameFailure(bool schedule) + { + ManualTaskScheduler scheduler = new(); + InvalidOperationException expected = new("function failed"); + List values = []; + List completions = []; + CallbackWitnessAsync observer = new( + (value, _) => + { + values.Add(value); + return default; + }, + null, + result => + { + completions.Add(result); + return default; + }); + await using StartSubscription subscription = new(observer, () => throw expected, schedule ? scheduler : null); + var running = subscription.ExecuteAsync(CancellationToken.None).AsTask(); + if (schedule) + { + await Assert.That(completions).IsEmpty(); + scheduler.RunNext(); + } + + await running; + await Assert.That(values).IsEmpty(); + await Assert.That(completions).Count().IsEqualTo(1); + await Assert.That(completions[0].Exception).IsSameReferenceAs(expected); + } + + /// Cancellation before dispatch prevents the user function from running. + /// The test operation. + [Test] + public async Task Function_WhenCanceledBeforeDispatch_ThenDoesNotRun() + { + ManualTaskScheduler scheduler = new(); + using CancellationTokenSource cancellation = new(); + var calls = 0; + List completions = []; + CallbackWitnessAsync observer = new(static (_, _) => default, null, result => + { + completions.Add(result); + return default; + }); + await using StartSubscription subscription = new(observer, () => ++calls, scheduler); + var running = subscription.ExecuteAsync(cancellation.Token).AsTask(); + await cancellation.CancelAsync(); + scheduler.RunNext(); + await running; + await Assert.That(calls).IsEqualTo(0); + await Assert.That(completions).Count().IsEqualTo(1); + await Assert.That(completions[0].Exception).IsTypeOf(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs index c92b2511..99a5b883 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TransformationOperatorTests.cs @@ -611,63 +611,15 @@ public async Task WhenPrependSourceThrowsAndOnCompletedThrows_ThenSecondaryExcep public void WhenYieldNullSource_ThenThrowsArgumentNull() => Assert.Throws(static () => SignalAsyncReactiveExtensions.Yield(null!)); - /// Verifies that Yield forwards all elements from the source sequence. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenYield_ThenForwardsAllElements() - { - const int Expected2 = 2; - const int Expected3 = 3; - const int Expected4 = 4; - const int Expected5 = 5; - const int SourceValueCount = 5; - - var result = await SignalAsync.Range(1, SourceValueCount).Yield().ToListAsync(); - await Assert.That(result).IsCollectionEqualTo([1, Expected2, Expected3, Expected4, Expected5]); - } - - /// Verifies that Yield forwards completion from the source sequence. - /// A representing the asynchronous test operation. - [Test] - public async Task WhenYield_ThenForwardsCompletion() - { - Result? capturedResult = null; - TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - await using var sub = await SignalAsync.Return(SentinelValue) - .Yield() - .SubscribeAsync(static (_, _) => default, null, result => - { - capturedResult = result; - _ = tcs.TrySetResult(); - return default; - }); - await tcs.Task; - await Assert.That(capturedResult).IsNotNull(); - await Assert.That(capturedResult!.Value.IsSuccess).IsTrue(); - } - - /// Verifies that Yield forwards errors from the source sequence. - /// A representing the asynchronous test operation. + /// Yield constructs the wrapper that captures the subscriber's context. + /// The test operation. [Test] - public async Task WhenYieldSourceErrors_ThenForwardsError() + public async Task WhenYield_ThenWrapsSource() { - Result? capturedResult = null; - TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - var source = SignalAsync.Create(static async (observer, ct) => - { - await observer.OnNextAsync(1, ct); - await observer.OnCompletedAsync(Result.Failure(new InvalidOperationException("yield error"))); - return DisposableAsync.Empty; - }); - await using var sub = await source.Yield().SubscribeAsync(static (_, _) => default, null, result => - { - capturedResult = result; - _ = tcs.TrySetResult(); - return default; - }); - await tcs.Task; - await Assert.That(capturedResult).IsNotNull(); - await Assert.That(capturedResult!.Value.IsSuccess).IsFalse(); + var source = SignalAsync.Return(SentinelValue); + var observed = source.Yield(); + await Assert.That(observed).IsTypeOf>(); + await Assert.That(observed).IsNotSameReferenceAs(source); } /// Verifies that the three-argument GroupBy overload throws when the source parameter is null. diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs index 62c51891..45aeb85b 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaSchedulerTests.cs @@ -3,109 +3,153 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Concurrency; +using System.Reactive.Disposables; using Avalonia.Threading; using ReactiveUI.Primitives.Reactive.Concurrency; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.Avalonia.Reactive.Tests; -/// Tests for against a pumped Avalonia headless dispatcher. +/// Tests scheduler ordering and cancellation through controlled dispatch callbacks. public sealed class AvaloniaSchedulerTests { - /// Verifies constructor validation. - /// A task representing the asynchronous operation. + /// The second value in an ordered batch. + private const int SecondValue = 2; + + /// Public constructors reject a missing dispatcher. + /// The test operation. [Test] - public async Task ConstructorRejectsNullDispatcher() => + public async Task ConstructorRejectsNullDispatcher() + { await Assert.That(static () => new AvaloniaScheduler(null!)).ThrowsExactly(); + await Assert.That(static () => new AvaloniaScheduler(null!, DispatcherPriority.Normal)) + .ThrowsExactly(); + } - /// Verifies the singleton uses Avalonia's UI dispatcher and legacy background priority. - /// A task representing the asynchronous operation. + /// Constructors and the singleton retain the selected dispatcher and priority. + /// The test operation. [Test] - public async Task InstanceUsesUiDispatcherAndBackgroundPriority() + public async Task ConstructorsAndSingletonPreserveDispatcherAndPriority() { - var (scheduler, dispatcher) = await AvaloniaTestSession.Instance.Dispatch( - static () => (AvaloniaScheduler.Instance, Dispatcher.UIThread), - CancellationToken.None); - - await Assert.That(scheduler.Dispatcher).IsSameReferenceAs(dispatcher); - await Assert.That(scheduler.Priority).IsEqualTo(DispatcherPriority.Background); - await Assert.That(scheduler).IsSameReferenceAs(AvaloniaScheduler.Instance); + var dispatcher = Dispatcher.UIThread; + AvaloniaScheduler defaults = new(dispatcher); + AvaloniaScheduler selected = new(dispatcher, DispatcherPriority.Normal); + await Assert.That(defaults.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(defaults.Priority).IsEqualTo(DispatcherPriority.Background); + await Assert.That(selected.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(selected.Priority).IsEqualTo(DispatcherPriority.Normal); + await Assert.That(AvaloniaScheduler.Instance.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(AvaloniaScheduler.Instance.Priority).IsEqualTo(DispatcherPriority.Background); } - /// Verifies immediate scheduler work is posted to and executed on the selected dispatcher thread. - /// A task representing the asynchronous operation. + /// Immediate and nonpositive delays share an ordered batch that skips cancelled work. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnDispatcherThread() + public async Task ScheduleCoalescesOrderedWorkAndSkipsCancellation() { - var (dispatcherThreadId, executionThreadId) = await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - var dispatcherThreadId = Environment.CurrentManagedThreadId; - AvaloniaScheduler scheduler = new(Dispatcher.UIThread); - TaskCompletionSource completion = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - _ = scheduler.Schedule( - () => completion.TrySetResult(Environment.CurrentManagedThreadId)); - - return (DispatcherThreadId: dispatcherThreadId, ExecutionThreadId: await completion.Task); - }, - CancellationToken.None); - - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + List values = []; + using var first = scheduler.Schedule(() => values.Add(1)); + var cancelled = scheduler.Schedule(TimeSpan.Zero, () => values.Add(0)); + using var last = scheduler.Schedule(TimeSpan.FromTicks(-1), () => values.Add(SecondValue)); + cancelled.Dispose(); + await Assert.That(values).IsEmpty(); + await Assert.That(dispatcher.Drains).Count().IsEqualTo(1); + await Assert.That(dispatcher.Delays).IsEmpty(); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies due work runs on the selected dispatcher at the configured priority. - /// A task representing the asynchronous operation. + /// Work scheduled during a callback waits for a later posted batch. + /// The test operation. [Test] - public async Task DueScheduleExecutesOnDispatcherThread() + public async Task ReentrantScheduleRunsInTheNextBatch() { - var (dispatcherThreadId, executionThreadId, priority) = await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - var dispatcherThreadId = Environment.CurrentManagedThreadId; - AvaloniaScheduler scheduler = new(Dispatcher.UIThread, DispatcherPriority.Normal); - TaskCompletionSource completion = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - _ = scheduler.Schedule( - TimeSpan.Zero, - () => completion.TrySetResult(Environment.CurrentManagedThreadId)); - - return ( - DispatcherThreadId: dispatcherThreadId, - ExecutionThreadId: await completion.Task, - scheduler.Priority); - }, - CancellationToken.None); - - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); - await Assert.That(priority).IsEqualTo(DispatcherPriority.Normal); + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + List values = []; + using var first = scheduler.Schedule(() => + { + values.Add(1); + _ = scheduler.Schedule(() => values.Add(SecondValue)); + }); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1], EqualityComparer.Default, CollectionOrdering.Matching); + await Assert.That(dispatcher.Drains).Count().IsEqualTo(1); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies disposing queued work prevents execution. - /// A task representing the asynchronous operation. + /// Delayed work observes its due time and cancellation before or after delivery. + /// Whether cancellation happens before delivery. + /// The test operation. [Test] - public async Task DueScheduleCanBeCancelled() + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleWaitsForItsCallback(bool cancelBeforeDelivery) { - var executed = await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - AvaloniaScheduler scheduler = new(Dispatcher.UIThread); - TaskCompletionSource following = new(TaskCreationOptions.RunContinuationsAsynchronously); - var executed = false; - - var disposable = scheduler.Schedule( - TimeSpan.Zero, - () => executed = true); - disposable.Dispose(); - - _ = scheduler.Schedule(TimeSpan.Zero, following.SetResult); + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + BooleanDisposable resource = new(); + var calls = 0; + var delay = TimeSpan.FromTicks(1); + var subscription = scheduler.Schedule(resource, delay, (_, state) => + { + calls++; + return state; + }); + await Assert.That(calls).IsEqualTo(0); + await Assert.That(dispatcher.Drains).IsEmpty(); + var pending = dispatcher.Delays.Dequeue(); + await Assert.That(pending.Due).IsEqualTo(delay); + if (cancelBeforeDelivery) + { + subscription.Dispose(); + } + + pending.Work(); + await Assert.That(calls).IsEqualTo(cancelBeforeDelivery ? 0 : 1); + subscription.Dispose(); + await Assert.That(pending.Cancellation.IsDisposed).IsTrue(); + await Assert.That(resource.IsDisposed).IsEqualTo(!cancelBeforeDelivery); + } - await following.Task; - return executed; - }, - CancellationToken.None); + /// Both scheduling overloads reject missing actions. + /// The test operation. + [Test] + public async Task ScheduleRejectsNullActions() + { + var scheduler = new ManualDispatcher().Create(); + await Assert.That(() => scheduler.Schedule(0, (Func)null!)) + .ThrowsExactly(); + await Assert.That(() => scheduler.Schedule(0, TimeSpan.Zero, (Func)null!)) + .ThrowsExactly(); + } - await Assert.That(executed).IsFalse(); + /// Retains callbacks until the test delivers them. + private sealed class ManualDispatcher + { + /// Gets the posted batches. + public Queue Drains { get; } = new(); + + /// Gets delayed callbacks and their cancellation handles. + public Queue<(Action Work, TimeSpan Due, BooleanDisposable Cancellation)> Delays { get; } = new(); + + /// Creates a scheduler controlled by these callbacks. + /// The scheduler. + public AvaloniaScheduler Create() => + new(Dispatcher.UIThread, DispatcherPriority.Normal, Drains.Enqueue, ScheduleDelayed); + + /// Retains delayed work without running it. + /// The callback to retain. + /// The requested delay. + /// The cancellation handle. + private BooleanDisposable ScheduleDelayed(Action work, TimeSpan dueTime) + { + BooleanDisposable cancellation = new(); + Delays.Enqueue((work, dueTime, cancellation)); + return cancellation; + } } } diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaTestSession.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaTestSession.cs deleted file mode 100644 index 87e318b3..00000000 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/AvaloniaTestSession.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using Avalonia; -using Avalonia.Headless; - -namespace ReactiveUI.Primitives.Avalonia.Reactive.Tests; - -/// Provides one process-wide Avalonia headless session with a continuously pumped UI thread. -internal static class AvaloniaTestSession -{ - /// Process-wide headless session. - private static readonly Lazy Session = - new( - static () => HeadlessUnitTestSession.StartNew(typeof(Application)), - LazyThreadSafetyMode.ExecutionAndPublication); - - /// Gets the shared headless test session. - internal static HeadlessUnitTestSession Instance => Session.Value; -} diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests.csproj b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests.csproj index 1f1ac570..6fba2f73 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests/ReactiveUI.Primitives.Avalonia.Reactive.Tests.csproj @@ -7,7 +7,6 @@ - diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs index 5bf305e3..91bc96be 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs @@ -4,88 +4,180 @@ using System.Runtime.CompilerServices; using Avalonia.Threading; +using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.Avalonia.Tests; -/// Tests for against a pumped Avalonia headless dispatcher. +/// Tests scheduler ordering and cancellation through controlled dispatch callbacks. public sealed class AvaloniaSchedulerTests { - /// Verifies constructor validation. - /// A task representing the asynchronous operation. + /// The second value in an ordered batch. + private const int SecondValue = 2; + + /// Public constructors reject a missing dispatcher. + /// The test operation. [Test] - public async Task ConstructorRejectsNullDispatcher() => + public async Task ConstructorRejectsNullDispatcher() + { await Assert.That(static () => new AvaloniaScheduler(null!)).ThrowsExactly(); + await Assert.That(static () => new AvaloniaScheduler(null!, DispatcherPriority.Normal)) + .ThrowsExactly(); + } - /// Verifies the singleton uses Avalonia's UI dispatcher and legacy background priority. - /// A task representing the asynchronous operation. + /// Constructors and the singleton retain the selected dispatcher and priority. + /// The test operation. [Test] - public async Task InstanceUsesUiDispatcherAndBackgroundPriority() + public async Task ConstructorsAndSingletonPreserveDispatcherAndPriority() { - var (scheduler, dispatcher) = await AvaloniaTestSession.Instance.Dispatch( - static () => (AvaloniaScheduler.Instance, Dispatcher.UIThread), - CancellationToken.None); - await Assert.That(scheduler.Dispatcher).IsSameReferenceAs(dispatcher); - await Assert.That(scheduler.Priority).IsEqualTo(DispatcherPriority.Background); - await Assert.That(scheduler).IsSameReferenceAs(AvaloniaScheduler.Instance); - await Assert.That(scheduler.Now).IsGreaterThan(DateTimeOffset.MinValue); + var dispatcher = Dispatcher.UIThread; + AvaloniaScheduler defaults = new(dispatcher); + AvaloniaScheduler selected = new(dispatcher, DispatcherPriority.Normal); + await Assert.That(defaults.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(defaults.Priority).IsEqualTo(DispatcherPriority.Background); + await Assert.That(selected.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(selected.Priority).IsEqualTo(DispatcherPriority.Normal); + await Assert.That(AvaloniaScheduler.Instance.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(AvaloniaScheduler.Instance.Priority).IsEqualTo(DispatcherPriority.Background); } - /// Verifies immediate work is posted to and executed on the selected dispatcher thread. - /// A task representing the asynchronous operation. + /// Immediate and already-due items share an ordered batch that skips cancelled work. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnDispatcherThread() + public async Task ScheduleCoalescesOrderedWorkAndSkipsCancellation() { - var (dispatcherThreadId, executionThreadId) = await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - var dispatcherThreadId = Environment.CurrentManagedThreadId; - AvaloniaScheduler scheduler = new(Dispatcher.UIThread); - TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); - scheduler.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId))); - return (dispatcherThreadId, await completion.Task); - }, - CancellationToken.None); - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + List values = []; + RecordingWorkItem cancelled = new(() => values.Add(0)); + scheduler.Schedule(new RecordingWorkItem(() => values.Add(1))); + scheduler.Schedule(cancelled); + scheduler.Schedule(new RecordingWorkItem(() => values.Add(SecondValue)), long.MinValue); + cancelled.Dispose(); + await Assert.That(values).IsEmpty(); + await Assert.That(dispatcher.Drains).Count().IsEqualTo(1); + await Assert.That(dispatcher.Delays).IsEmpty(); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies due work executes on the selected dispatcher. - /// A task representing the asynchronous operation. + /// Work scheduled during a callback waits for a later posted batch. + /// The test operation. [Test] - public async Task DueScheduleExecutesOnDispatcherThread() + public async Task ReentrantScheduleRunsInTheNextBatch() { - var (dispatcherThreadId, executionThreadId, priority) = await AvaloniaTestSession.Instance.Dispatch( - static async () => - { - var dispatcherThreadId = Environment.CurrentManagedThreadId; - AvaloniaScheduler scheduler = new(Dispatcher.UIThread, DispatcherPriority.Normal); - TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); - scheduler.Schedule( - new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId)), - scheduler.Timestamp); - return (dispatcherThreadId, await completion.Task, scheduler.Priority); - }, - CancellationToken.None); - await Assert.That(executionThreadId).IsEqualTo(dispatcherThreadId); - await Assert.That(priority).IsEqualTo(DispatcherPriority.Normal); + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + List values = []; + scheduler.Schedule(new RecordingWorkItem(() => + { + values.Add(1); + scheduler.Schedule(new RecordingWorkItem(() => values.Add(SecondValue))); + })); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1], EqualityComparer.Default, CollectionOrdering.Matching); + await Assert.That(dispatcher.Drains).Count().IsEqualTo(1); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); + } + + /// A rejected post retains work for a later accepted post. + /// The test operation. + [Test] + public async Task RejectedPostRetainsQueuedWork() + { + ManualDispatcher dispatcher = new() { AcceptsPosts = false }; + var scheduler = dispatcher.Create(); + List values = []; + scheduler.Schedule(new RecordingWorkItem(() => values.Add(1))); + await Assert.That(dispatcher.Drains).IsEmpty(); + dispatcher.AcceptsPosts = true; + scheduler.Schedule(new RecordingWorkItem(() => values.Add(SecondValue))); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); + } + + /// Delayed work retains its due timestamp and observes cancellation before delivery. + /// Whether cancellation happens before delivery. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleWaitsForItsCallback(bool cancel) + { + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + var calls = 0; + RecordingWorkItem item = new(() => calls++); + scheduler.Schedule(item, long.MaxValue); + await Assert.That(calls).IsEqualTo(0); + await Assert.That(dispatcher.Drains).IsEmpty(); + var pending = dispatcher.Delays.Dequeue(); + await Assert.That(pending.Item).IsSameReferenceAs(item); + await Assert.That(pending.Due).IsEqualTo(long.MaxValue); + if (cancel) + { + item.Dispose(); + } + + DispatchSequencerState.RunIfActive(pending.Item); + await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); } - /// Verifies the sequencer validates both work-item overloads. - /// A task representing the asynchronous operation. + /// Both scheduling overloads reject missing work. + /// The test operation. [Test] public async Task ScheduleRejectsNullWorkItems() { - var scheduler = await AvaloniaTestSession.Instance.Dispatch( - static () => new AvaloniaScheduler(Dispatcher.UIThread), - CancellationToken.None); + var scheduler = new ManualDispatcher().Create(); await Assert.That(() => scheduler.Schedule(null!)).ThrowsExactly(); - await Assert.That(() => scheduler.Schedule(null!, scheduler.Timestamp)).ThrowsExactly(); + await Assert.That(() => scheduler.Schedule(null!, long.MaxValue)).ThrowsExactly(); } - /// Work item backed by an action. - /// Action to invoke. - private sealed class DelegateWorkItem(Action action) : IWorkItem + /// Retains callbacks until the test delivers them. + private sealed class ManualDispatcher { + /// Gets the posted batches. + public Queue Drains { get; } = new(); + + /// Gets work awaiting its due timestamp. + public Queue<(IWorkItem Item, long Due)> Delays { get; } = new(); + + /// Gets or sets whether a post is accepted. + public bool AcceptsPosts { get; set; } = true; + + /// Creates a scheduler controlled by these callbacks. + /// The scheduler. + public AvaloniaScheduler Create() => + new(Dispatcher.UIThread, DispatcherPriority.Normal, Post, (item, due) => Delays.Enqueue((item, due))); + + /// Retains an accepted callback without running it. + /// The callback to retain. + /// Whether the callback was accepted. + private bool Post(Action drain) + { + if (!AcceptsPosts) + { + return false; + } + + Drains.Enqueue(drain); + return true; + } + } + + /// Records execution and supports cancellation. + /// The callback to run. + private sealed class RecordingWorkItem(Action action) : IWorkItem, IsDisposed + { + /// + public bool IsDisposed { get; private set; } + + /// + public void Dispose() => IsDisposed = true; + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Execute() => action(); diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaTestSession.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaTestSession.cs deleted file mode 100644 index 86006465..00000000 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaTestSession.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -using Avalonia; -using Avalonia.Headless; - -namespace ReactiveUI.Primitives.Avalonia.Tests; - -/// Provides one process-wide Avalonia headless session with a continuously pumped UI thread. -internal static class AvaloniaTestSession -{ - /// Process-wide headless session. - private static readonly Lazy Session = - new( - static () => HeadlessUnitTestSession.StartNew(typeof(Application)), - LazyThreadSafetyMode.ExecutionAndPublication); - - /// Gets the shared headless test session. - internal static HeadlessUnitTestSession Instance => Session.Value; -} diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/ReactiveUI.Primitives.Avalonia.Tests.csproj b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/ReactiveUI.Primitives.Avalonia.Tests.csproj index e980cd30..1a53fdf7 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/ReactiveUI.Primitives.Avalonia.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/ReactiveUI.Primitives.Avalonia.Tests.csproj @@ -7,7 +7,6 @@ - diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs index 10f17454..9b8657a0 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Reactive.Tests/BlazorRendererSequencerTests.cs @@ -14,6 +14,9 @@ namespace ReactiveUI.Primitives.Blazor.Reactive.Tests; /// Tests for as an driven through a fake renderer delegate. public sealed class BlazorRendererSequencerTests { + /// The failure reported by a rejected renderer operation. + private const string RendererFailure = "renderer rejected"; + /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; @@ -41,12 +44,14 @@ public async Task ToSequencerRejectsNullDispatcher() => [Test] public async Task DispatcherSchedulerExecutesWork() { - var scheduler = Dispatcher.CreateDefault().ToSequencer(); - TaskCompletionSource executed = new(TaskCreationOptions.RunContinuationsAsynchronously); + FakeRenderer renderer = new(); + var scheduler = renderer.ToSequencer(); + var executed = false; - _ = scheduler.Schedule(() => executed.TrySetResult(true)); + _ = scheduler.Schedule(() => executed = true); - await Assert.That(await executed.Task).IsTrue(); + await Assert.That(executed).IsTrue(); + await Assert.That(renderer.InvokeCount).IsEqualTo(1); } /// Verifies renderer-task faults reach the unhandled-exception handler instead of vanishing. @@ -55,7 +60,7 @@ public async Task DispatcherSchedulerExecutesWork() public async Task FaultedRendererTaskRoutesToHandler() { TaskCompletionSource observed = new(TaskCreationOptions.RunContinuationsAsynchronously); - InvalidOperationException fault = new("renderer rejected"); + InvalidOperationException fault = new(RendererFailure); BlazorRendererSequencer scheduler = new(_ => Task.FromException(fault)); scheduler.UnhandledExceptionHandler = ex => observed.TrySetResult(ex); @@ -64,6 +69,86 @@ public async Task FaultedRendererTaskRoutesToHandler() await Assert.That(await observed.Task).IsSameReferenceAs(fault); } + /// A successful renderer task needs no fault registration. + /// A task representing the asynchronous test. + [Test] + public async Task ObserveFaults_SuccessfulTask_SkipsRegistration() + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + var registered = false; + + sequencer.ObserveFaults(Task.CompletedTask, (_, _) => registered = true); + + await Assert.That(registered).IsFalse(); + } + + /// A pending renderer task retains its owner and forwards a later base exception. + /// A task representing the asynchronous test. + [Test] + public async Task ObserveFaults_PendingTask_ForwardsLaterFault() + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + TaskCompletionSource renderer = new(TaskCreationOptions.RunContinuationsAsynchronously); + List observed = []; + sequencer.UnhandledExceptionHandler = observed.Add; + Task? registeredTask = null; + BlazorRendererSequencer? registeredOwner = null; + sequencer.ObserveFaults(renderer.Task, (task, owner) => + { + registeredTask = task; + registeredOwner = owner; + }); + + await Assert.That(ReferenceEquals(registeredTask, renderer.Task)).IsTrue(); + await Assert.That(registeredOwner).IsSameReferenceAs(sequencer); + await Assert.That(observed).IsEmpty(); + + InvalidOperationException fault = new(RendererFailure); + renderer.SetException(new AggregateException(fault)); + registeredOwner!.CompleteRendererTask(registeredTask!); + + await Assert.That(observed).HasSingleItem(); + await Assert.That(observed[0]).IsSameReferenceAs(fault); + } + + /// Pending, successful, and canceled renderer tasks produce no fault notification. + /// A task representing the asynchronous test. + [Test] + public async Task CompleteRendererTask_WithoutFault_IgnoresTask() + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + List observed = []; + sequencer.UnhandledExceptionHandler = observed.Add; + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + + sequencer.CompleteRendererTask(pending.Task); + sequencer.CompleteRendererTask(Task.CompletedTask); + sequencer.CompleteRendererTask(Task.FromCanceled(new(true))); + + await Assert.That(observed).IsEmpty(); + } + + /// A configured handler receives the fault; otherwise the fallback receives it. + /// True when a renderer fault handler is configured. + /// A task representing the asynchronous test. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task HandleFault_HandlerSelection_UsesOneDestination(bool hasHandler) + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + List handled = []; + List rethrown = []; + sequencer.UnhandledExceptionHandler = hasHandler ? handled.Add : null; + InvalidOperationException fault = new(RendererFailure); + + sequencer.HandleFault(fault, rethrown.Add); + + await Assert.That(handled.Count).IsEqualTo(hasHandler ? 1 : 0); + await Assert.That(rethrown.Count).IsEqualTo(hasHandler ? 0 : 1); + await Assert.That((hasHandler ? handled : rethrown)[0]).IsSameReferenceAs(fault); + } + /// Verifies reactive component observation guards reject null inputs. /// A representing the asynchronous operation. [Test] @@ -155,20 +240,30 @@ public async Task ImmediateBurstExecutesInOrder() } /// Fake renderer that runs marshalled work synchronously and records how often it was invoked. - private sealed class FakeRenderer + private sealed class FakeRenderer : Dispatcher { /// Gets the number of times was called. public int InvokeCount { get; private set; } - /// Runs the supplied work synchronously, mimicking ComponentBase.InvokeAsync. - /// The work to run. - /// A completed task. - public Task InvokeAsync(Action action) + /// + public override bool CheckAccess() => true; + + /// + public override Task InvokeAsync(Action workItem) { InvokeCount++; - action(); + workItem(); return Task.CompletedTask; } + + /// + public override Task InvokeAsync(Func workItem) => workItem(); + + /// + public override Task InvokeAsync(Func workItem) => Task.FromResult(workItem()); + + /// + public override Task InvokeAsync(Func> workItem) => workItem(); } /// Test component that exposes protected reactive component members. diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs index 5918118b..19d9f2fd 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs @@ -14,6 +14,9 @@ namespace ReactiveUI.Primitives.Blazor.Tests; /// Tests for driven through a fake renderer delegate. public sealed class BlazorRendererSequencerTests { + /// The failure reported by a rejected renderer operation. + private const string RendererFailure = "renderer rejected"; + /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; @@ -41,12 +44,14 @@ public async Task ToSequencerRejectsNullDispatcher() => [Test] public async Task DispatcherSequencerExecutesWork() { - var sequencer = Dispatcher.CreateDefault().ToSequencer(); - TaskCompletionSource executed = new(TaskCreationOptions.RunContinuationsAsynchronously); + FakeRenderer renderer = new(); + var sequencer = renderer.ToSequencer(); + var executed = false; - sequencer.Schedule(new DelegateWorkItem(() => executed.TrySetResult(true))); + sequencer.Schedule(new DelegateWorkItem(() => executed = true)); - await Assert.That(await executed.Task).IsTrue(); + await Assert.That(executed).IsTrue(); + await Assert.That(renderer.InvokeCount).IsEqualTo(1); } /// Verifies renderer-task faults reach the unhandled-exception handler instead of vanishing. @@ -55,7 +60,7 @@ public async Task DispatcherSequencerExecutesWork() public async Task FaultedRendererTaskRoutesToHandler() { TaskCompletionSource observed = new(TaskCreationOptions.RunContinuationsAsynchronously); - InvalidOperationException fault = new("renderer rejected"); + InvalidOperationException fault = new(RendererFailure); BlazorRendererSequencer sequencer = new(_ => Task.FromException(fault)); sequencer.UnhandledExceptionHandler = ex => observed.TrySetResult(ex); @@ -64,6 +69,86 @@ public async Task FaultedRendererTaskRoutesToHandler() await Assert.That(await observed.Task).IsSameReferenceAs(fault); } + /// A successful renderer task needs no fault registration. + /// A task representing the asynchronous test. + [Test] + public async Task ObserveFaults_SuccessfulTask_SkipsRegistration() + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + var registered = false; + + sequencer.ObserveFaults(Task.CompletedTask, (_, _) => registered = true); + + await Assert.That(registered).IsFalse(); + } + + /// A pending renderer task retains its owner and forwards a later base exception. + /// A task representing the asynchronous test. + [Test] + public async Task ObserveFaults_PendingTask_ForwardsLaterFault() + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + TaskCompletionSource renderer = new(TaskCreationOptions.RunContinuationsAsynchronously); + List observed = []; + sequencer.UnhandledExceptionHandler = observed.Add; + Task? registeredTask = null; + BlazorRendererSequencer? registeredOwner = null; + sequencer.ObserveFaults(renderer.Task, (task, owner) => + { + registeredTask = task; + registeredOwner = owner; + }); + + await Assert.That(ReferenceEquals(registeredTask, renderer.Task)).IsTrue(); + await Assert.That(registeredOwner).IsSameReferenceAs(sequencer); + await Assert.That(observed).IsEmpty(); + + InvalidOperationException fault = new(RendererFailure); + renderer.SetException(new AggregateException(fault)); + registeredOwner!.CompleteRendererTask(registeredTask!); + + await Assert.That(observed).HasSingleItem(); + await Assert.That(observed[0]).IsSameReferenceAs(fault); + } + + /// Pending, successful, and canceled renderer tasks produce no fault notification. + /// A task representing the asynchronous test. + [Test] + public async Task CompleteRendererTask_WithoutFault_IgnoresTask() + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + List observed = []; + sequencer.UnhandledExceptionHandler = observed.Add; + TaskCompletionSource pending = new(TaskCreationOptions.RunContinuationsAsynchronously); + + sequencer.CompleteRendererTask(pending.Task); + sequencer.CompleteRendererTask(Task.CompletedTask); + sequencer.CompleteRendererTask(Task.FromCanceled(new(true))); + + await Assert.That(observed).IsEmpty(); + } + + /// A configured handler receives the fault; otherwise the fallback receives it. + /// True when a renderer fault handler is configured. + /// A task representing the asynchronous test. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task HandleFault_HandlerSelection_UsesOneDestination(bool hasHandler) + { + BlazorRendererSequencer sequencer = new(static _ => Task.CompletedTask); + List handled = []; + List rethrown = []; + sequencer.UnhandledExceptionHandler = hasHandler ? handled.Add : null; + InvalidOperationException fault = new(RendererFailure); + + sequencer.HandleFault(fault, rethrown.Add); + + await Assert.That(handled.Count).IsEqualTo(hasHandler ? 1 : 0); + await Assert.That(rethrown.Count).IsEqualTo(hasHandler ? 0 : 1); + await Assert.That((hasHandler ? handled : rethrown)[0]).IsSameReferenceAs(fault); + } + /// Verifies reactive component observation guards reject null inputs. /// A representing the asynchronous operation. [Test] @@ -170,20 +255,30 @@ private sealed class DelegateWorkItem : IWorkItem } /// Fake renderer that runs marshalled work synchronously and records how often it was invoked. - private sealed class FakeRenderer + private sealed class FakeRenderer : Dispatcher { /// Gets the number of times was called. public int InvokeCount { get; private set; } - /// Runs the supplied work synchronously, mimicking ComponentBase.InvokeAsync. - /// The work to run. - /// A completed task. - public Task InvokeAsync(Action action) + /// + public override bool CheckAccess() => true; + + /// + public override Task InvokeAsync(Action workItem) { InvokeCount++; - action(); + workItem(); return Task.CompletedTask; } + + /// + public override Task InvokeAsync(Func workItem) => workItem(); + + /// + public override Task InvokeAsync(Func workItem) => Task.FromResult(workItem()); + + /// + public override Task InvokeAsync(Func> workItem) => workItem(); } /// Test component that exposes protected reactive component members. diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs index 58a3cf80..5292a705 100644 --- a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ContinuationTests.cs @@ -3,127 +3,290 @@ // See the LICENSE file in the project root for full license information. using System.Reactive; +using System.Runtime.CompilerServices; namespace ReactiveUI.Primitives.Extensions.Tests; -/// Tests for , the phase-barrier lock that serialises emissions. +/// Tests delivery and release ordering for . public class ContinuationTests { - /// Item pushed while the continuation holds the lock, which the barrier drops. + /// An item offered while the current handoff holds the gate. private const int DroppedItem = 2; - /// Verifies pushes the item downstream, locks, and completes once the phase is signalled by an unlock. - /// A representing the asynchronous test operation. + /// The emitted item carries the release handle, and its task remains pending until release. + /// True to acquire the handoff through the value-task overload. + /// A task representing the asynchronous test. [Test] - public async Task WhenLockValueTaskNotLocked_ThenEmitsAndCompletesOnUnlock() + [Arguments(false)] + [Arguments(true)] + public async Task Lock_FreeGate_EmitsAndCompletesOnUnlock(bool valueTask) { using Continuation continuation = new(); List values = []; - var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + IDisposable? handle = null; + var observer = Observer.Create<(int Value, IDisposable Sync)>(value => + { + values.Add(value.Value); + handle = value.Sync; + }); + + var lockTask = valueTask + ? continuation.LockValueTask(1, observer).AsTask() + : continuation.Lock(1, observer); + + await Assert.That(lockTask.IsCompleted).IsFalse(); + await Assert.That(continuation.CompletedPhases).IsEqualTo(0); + await Assert.That(handle).IsSameReferenceAs(continuation); - var lockTask = continuation.LockValueTask(1, observer); var unlockTask = continuation.UnLock(); - await lockTask.AsTask(); + await Assert.That(lockTask.IsCompletedSuccessfully).IsTrue(); + await Assert.That(unlockTask.IsCompletedSuccessfully).IsTrue(); + await lockTask; await unlockTask; - await Assert.That(values.Count).IsEqualTo(1); + await Assert.That(values).HasSingleItem(); await Assert.That(values[0]).IsEqualTo(1); - await Assert.That(continuation.CompletedPhases).IsGreaterThanOrEqualTo(1); + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); } - /// Verifies a second taken under the lock returns a completed default value task and drops the item. - /// A representing the asynchronous test operation. + /// An item offered while the gate is held is dropped without releasing the pending handoff. + /// True to acquire handoffs through the value-task overload. + /// A task representing the asynchronous test. [Test] - public async Task WhenLockValueTaskAlreadyLocked_ThenReturnsDefaultAndDropsItem() + [Arguments(false)] + [Arguments(true)] + public async Task Lock_HeldGate_DropsItem(bool valueTask) { using Continuation continuation = new(); List values = []; var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); - var first = continuation.LockValueTask(1, observer); - var second = continuation.LockValueTask(DroppedItem, observer); + var first = valueTask + ? continuation.LockValueTask(1, observer).AsTask() + : continuation.Lock(1, observer); + var second = valueTask + ? continuation.LockValueTask(DroppedItem, observer).AsTask() + : continuation.Lock(DroppedItem, observer); - await Assert.That(second.IsCompleted).IsTrue(); + await Assert.That(first.IsCompleted).IsFalse(); + await Assert.That(second.IsCompletedSuccessfully).IsTrue(); await second; - - var unlockTask = continuation.UnLock(); - await first.AsTask(); - await unlockTask; - - await Assert.That(values.Count).IsEqualTo(1); + await Assert.That(values).HasSingleItem(); await Assert.That(values[0]).IsEqualTo(1); + + _ = continuation.UnLock(); + await Assert.That(first.IsCompletedSuccessfully).IsTrue(); + await first; + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); } - /// Verifies (the Task overload) emits and completes on unlock. - /// A representing the asynchronous test operation. + /// A new lock after release starts a separate pending handoff. + /// True to acquire handoffs through the value-task overload. + /// A task representing the asynchronous test. [Test] - public async Task WhenLockNotLocked_ThenEmitsAndCompletesOnUnlock() + [Arguments(false)] + [Arguments(true)] + public async Task Lock_AfterUnlock_StartsNextPhase(bool valueTask) { using Continuation continuation = new(); - List values = []; - var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + var first = valueTask + ? continuation.LockValueTask(1, null).AsTask() + : continuation.Lock(1, null); + _ = continuation.UnLock(); + await Assert.That(first.IsCompletedSuccessfully).IsTrue(); + await first; - var lockTask = continuation.Lock(1, observer); - var unlockTask = continuation.UnLock(); + var second = valueTask + ? continuation.LockValueTask(DroppedItem, null).AsTask() + : continuation.Lock(DroppedItem, null); - await lockTask; - await unlockTask; + await Assert.That(second.IsCompleted).IsFalse(); + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); + + _ = continuation.UnLock(); + await Assert.That(second.IsCompletedSuccessfully).IsTrue(); + await second; + + await Assert.That(continuation.CompletedPhases).IsEqualTo(DroppedItem); + } + + /// A null observer still acquires a handoff that disposal releases once. + /// True to acquire the handoff through the value-task overload. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Dispose_PendingHandoffWithNullObserver_CompletesOnce(bool valueTask) + { + Continuation continuation = new(); + var handoff = valueTask + ? continuation.LockValueTask(1, null).AsTask() + : continuation.Lock(1, null); + + await Assert.That(handoff.IsCompleted).IsFalse(); - await Assert.That(values.Count).IsEqualTo(1); + continuation.Dispose(); + continuation.Dispose(); + + await Assert.That(handoff.IsCompletedSuccessfully).IsTrue(); + await handoff; + await Assert.That(continuation.UnLock().IsCompletedSuccessfully).IsTrue(); + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); } - /// Verifies a second taken under the lock returns a completed task and drops the item. - /// A representing the asynchronous test operation. + /// Disposal inside the observer releases the handoff without completing it before delivery returns. + /// True to acquire the handoff through the value-task overload. + /// A task representing the asynchronous test. [Test] - public async Task WhenLockAlreadyLocked_ThenReturnsCompletedAndDropsItem() + [Arguments(false)] + [Arguments(true)] + public async Task Dispose_DuringDelivery_CompletesAfterDelivery(bool valueTask) { using Continuation continuation = new(); - List values = []; - var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + long phasesDuringDelivery = -1; + var observer = Observer.Create<(int Value, IDisposable Sync)>(value => + { + value.Sync.Dispose(); + phasesDuringDelivery = continuation.CompletedPhases; + }); - var first = continuation.Lock(1, observer); - var second = continuation.Lock(DroppedItem, observer); + var handoff = valueTask + ? continuation.LockValueTask(1, observer).AsTask() + : continuation.Lock(1, observer); - await Assert.That(second.IsCompleted).IsTrue(); + await Assert.That(phasesDuringDelivery).IsEqualTo(0); + await Assert.That(handoff.IsCompletedSuccessfully).IsTrue(); + await handoff; + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); + } - var unlockTask = continuation.UnLock(); - await first; - await unlockTask; + /// An unlock inside delivery waits for that delivery to return. + /// A task representing the asynchronous test. + [Test] + public async Task UnLock_DuringDelivery_CompletesAfterDelivery() + { + using Continuation continuation = new(); + Task? release = null; + var completedDuringDelivery = true; + var observer = Observer.Create<(int Value, IDisposable Sync)>(_ => + { + release = continuation.UnLock(); + completedDuringDelivery = release.IsCompleted; + }); + + var handoff = continuation.Lock(1, observer); - await Assert.That(values.Count).IsEqualTo(1); + await Assert.That(completedDuringDelivery).IsFalse(); + await Assert.That(release!.IsCompletedSuccessfully).IsTrue(); + await Assert.That(handoff.IsCompletedSuccessfully).IsTrue(); + await handoff; + await release; + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); } - /// Verifies that unlocking a continuation that was never locked completes immediately. - /// A representing the asynchronous test operation. + /// Finishing a newer handoff cannot complete an earlier delivery that has not returned. + /// A task representing the asynchronous test. [Test] - public async Task WhenUnlockNotLocked_ThenCompletesImmediately() + public async Task CompleteDelivery_ReleasedPhasePrecedesNewLock_PreservesPairing() { using Continuation continuation = new(); + var first = continuation.TryBeginPhase()!; + var firstRelease = continuation.UnLock(); + var second = continuation.TryBeginPhase()!; + continuation.CompleteDelivery(second); + + await Assert.That(firstRelease.IsCompleted).IsFalse(); + await Assert.That(second.Completion.Task.IsCompleted).IsFalse(); + await Assert.That(continuation.CompletedPhases).IsEqualTo(0); - await continuation.UnLock(); + var secondRelease = continuation.UnLock(); + + await Assert.That(secondRelease.IsCompletedSuccessfully).IsTrue(); + await Assert.That(firstRelease.IsCompleted).IsFalse(); + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); + + continuation.CompleteDelivery(first); + continuation.CompleteDelivery(first); + + await Assert.That(firstRelease.IsCompletedSuccessfully).IsTrue(); + await firstRelease; + await secondRelease; + await Assert.That(continuation.CompletedPhases).IsEqualTo(DroppedItem); + } + + /// Releasing and disposing a free gate never advances the completed phase count. + /// A task representing the asynchronous test. + [Test] + public async Task UnLockAndDispose_FreeGate_RemainNoOps() + { + Continuation continuation = new(); + + await Assert.That(continuation.UnLock().IsCompletedSuccessfully).IsTrue(); + await Assert.That(continuation.UnLock().IsCompletedSuccessfully).IsTrue(); + continuation.Dispose(); + continuation.Dispose(); + await Assert.That(continuation.UnLock().IsCompletedSuccessfully).IsTrue(); await Assert.That(continuation.CompletedPhases).IsEqualTo(0); } - /// Verifies disposing twice is an idempotent no-op on the second call. - /// A representing the asynchronous test operation. + /// A handoff started after disposal emits its item and reports disposal through its task. + /// True to acquire the handoff through the value-task overload. + /// A task representing the asynchronous test. [Test] - public async Task WhenDisposeTwice_ThenSecondDisposeIsNoOp() + [Arguments(false)] + [Arguments(true)] + public async Task Lock_AfterDispose_EmitsAndFaultsTask(bool valueTask) { Continuation continuation = new(); - Exception? caught = null; + List values = []; + var observer = Observer.Create<(int Value, IDisposable Sync)>(value => values.Add(value.Value)); + continuation.Dispose(); - try - { - continuation.Dispose(); - continuation.Dispose(); - } - catch (Exception ex) - { - caught = ex; - } + var handoff = valueTask + ? continuation.LockValueTask(1, observer).AsTask() + : continuation.Lock(1, observer); + var dropped = valueTask + ? continuation.LockValueTask(DroppedItem, observer).AsTask() + : continuation.Lock(DroppedItem, observer); + + await Assert.That(handoff.IsFaulted).IsTrue(); + await Assert.That(async () => await handoff).Throws(); + await Assert.That(dropped.IsCompletedSuccessfully).IsTrue(); + await dropped; + await Assert.That(values).HasSingleItem(); + await Assert.That(values[0]).IsEqualTo(1); + await Assert.That(continuation.CompletedPhases).IsEqualTo(0); + + var release = continuation.UnLock(); + await Assert.That(release.IsFaulted).IsTrue(); + await Assert.That(async () => await release).Throws(); + } + + /// Unmanaged disposal leaves the pending handoff available for an explicit release. + /// A task representing the asynchronous test. + [Test] + public async Task Dispose_UnmanagedOnly_DoesNotReleaseHandoff() + { + DerivedContinuation continuation = new(); + var handoff = continuation.Lock(1, null); - await Assert.That(caught).IsNull(); + continuation.DisposeUnmanaged(); + continuation.Dispose(); + + await Assert.That(handoff.IsCompleted).IsFalse(); + _ = continuation.UnLock(); + await Assert.That(handoff.IsCompletedSuccessfully).IsTrue(); + await handoff; + await Assert.That(continuation.CompletedPhases).IsEqualTo(1); + } + + /// Exposes unmanaged disposal without changing the public disposal path. + private sealed class DerivedContinuation : Continuation + { + /// Requests unmanaged disposal through the protected overload. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DisposeUnmanaged() => Dispose(false); } } diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ScheduledWaitTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ScheduledWaitTests.cs new file mode 100644 index 00000000..443dcecc --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Internal/ScheduledWaitTests.cs @@ -0,0 +1,37 @@ +// 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.Primitives.Extensions.Internal; + +namespace ReactiveUI.Primitives.Extensions.Tests.Internal; + +/// Tests synchronous wait and action ordering. +public class ScheduledWaitTests +{ + /// The action observes the state left by the wait operation. + /// A task representing the asynchronous test. + [Test] + public async Task Run_WaitReturns_ActionObservesWaitState() + { + var expectedDelay = TimeSpan.FromSeconds(1); + TimeSpan? observedDelay = null; + TimeSpan? actionDelay = null; + using var result = ScheduledWait.Run(expectedDelay, () => actionDelay = observedDelay, delay => observedDelay = delay); + await Assert.That(observedDelay).IsEqualTo(expectedDelay); + await Assert.That(actionDelay).IsEqualTo(expectedDelay); + } + + /// A failed wait prevents the action and preserves the original exception. + /// A task representing the asynchronous test. + [Test] + public async Task Run_WaitThrows_DoesNotInvokeAction() + { + var ran = false; + InvalidOperationException error = new("wait failed"); + var caught = await Assert.That(() => ScheduledWait.Run(TimeSpan.Zero, () => ran = true, _ => throw error)) + .ThrowsExactly(); + await Assert.That(caught).IsSameReferenceAs(error); + await Assert.That(ran).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs index 04b3d0f5..f3696308 100644 --- a/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/WasmSchedulerTests.cs @@ -430,6 +430,24 @@ public async Task AttachTimerReleasesATimerGivenToAnAlreadyCancelledItem() await Assert.That(ran).IsEqualTo(0); } + /// Final cancellation cleanup does not release a result or attached timer twice. + /// A task representing the asynchronous test. + [Test] + public async Task CancellationAfterPublication_ReleasesResultAndTimerOnce() + { + using var scheduler = CreateIsolatedScheduler(); + CountingDisposable result = new(); + CountingDisposable timer = new(); + WasmScheduler.StatefulWorkItem item = new(scheduler, 0, (_, _) => result); + item.Run(); + item.AttachTimer(timer); + item.Dispose(); + item.ReleaseCanceledResult(); + item.ReleaseCanceledTimer(); + await Assert.That(result.DisposeCount).IsEqualTo(1); + await Assert.That(timer.DisposeCount).IsEqualTo(1); + } + /// Verifies a dispatched periodic callback observes disposal before invoking the action. /// A representing the asynchronous operation. [Test] @@ -564,6 +582,16 @@ public async Task FinishingDrainSchedulesWorkPublishedDuringItsBatch() /// The isolated scheduler. private static WasmScheduler CreateIsolatedScheduler() => new(new ManualTimeProvider()); + /// Counts every release, including duplicate calls. + private sealed class CountingDisposable : IDisposable + { + /// Gets the number of release calls. + internal int DisposeCount { get; private set; } + + /// + public void Dispose() => DisposeCount++; + } + /// Stores timer callbacks until the test dispatches them. private sealed class ManualTimeProvider : TimeProvider { diff --git a/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs index 9ba2fc4e..f78dcb8b 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BroadcasterTests.cs @@ -19,6 +19,91 @@ public class BroadcasterTests /// The literal three. private const int Three = 3; + /// A stale observer snapshot cannot replace a newer addition. + /// The number of observers in the original snapshot. + /// A task representing the asynchronous test. + [Test] + [Arguments(0)] + [Arguments(One)] + [Arguments(Two)] + public async Task TryAdd_StaleSnapshot_PreservesCompetingAddition(int observerCount) + { + RecordingWitness first = new(); + RecordingWitness second = new(); + RecordingWitness competing = new(); + RecordingWitness incoming = new(); + IObserver[] initial = observerCount switch + { + 0 => [], + One => [first], + _ => [first, second], + }; + object? observers = observerCount switch + { + 0 => null, + One => first, + _ => initial, + }; + var stale = observers; + await Assert.That(Broadcaster.TryAdd(ref observers, stale, competing)).IsTrue(); + var current = observers; + + await Assert.That(Broadcaster.TryAdd(ref observers, stale, incoming)).IsFalse(); + await Assert.That(observers).IsSameReferenceAs(current); + + await Assert.That(Broadcaster.TryAdd(ref observers, current, incoming)).IsTrue(); + var actual = await Assert.That(observers).IsTypeOf[]>().And.IsNotNull(); + await Assert.That(actual.SequenceEqual(initial.Append(competing).Append(incoming))).IsTrue(); + } + + /// Removal retries against the current snapshot without losing a competing addition. + /// True when the original snapshot contains two observers. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TryRemove_StaleSnapshot_PreservesCompetingAddition(bool multiple) + { + RecordingWitness removed = new(); + RecordingWitness retained = new(); + RecordingWitness competing = new(); + object? observers = multiple ? new IObserver[] { removed, retained } : removed; + var stale = observers; + await Assert.That(Broadcaster.TryAdd(ref observers, stale, competing)).IsTrue(); + var current = observers; + + await Assert.That(Broadcaster.TryRemove(ref observers, stale, removed)).IsFalse(); + await Assert.That(observers).IsSameReferenceAs(current); + await Assert.That(Broadcaster.TryRemove(ref observers, current, removed)).IsTrue(); + + if (multiple) + { + var actual = await Assert.That(observers).IsTypeOf[]>().And.IsNotNull(); + await Assert.That(actual.SequenceEqual([retained, competing])).IsTrue(); + } + else + { + await Assert.That(observers).IsSameReferenceAs(competing); + } + } + + /// Removing an absent observer does not change an empty or single-observer slot. + /// True when no observer is registered. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TryRemove_AbsentObserver_LeavesSlotUnchanged(bool empty) + { + RecordingWitness retained = new(); + RecordingWitness missing = new(); + object? observers = empty ? null : retained; + var current = observers; + + await Assert.That(Broadcaster.TryRemove(ref observers, current, missing)).IsTrue(); + await Assert.That(ReferenceEquals(observers, current)).IsTrue(); + } + /// The equality operators compare the underlying observer set by reference. /// A task representing the asynchronous operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs index 14a96867..fbbe24d8 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/PrioritySemaphoreSignalTests.cs @@ -138,6 +138,39 @@ public async Task ReleaseDoesNotAllowNegativeCount() subscription.Dispose(); } + /// A release retries against a count changed by an intervening release. + /// The test operation. + [Test] + public async Task ReleaseWithStaleCountRetriesAgainstCurrentCapacity() + { + using PrioritySemaphoreSignal signal = new(SecondValue); + RecordingObserver observer = new(); + using var subscription = signal.Subscribe(observer); + signal.OnNext(FirstValue); + signal.OnNext(SecondValue); + signal.Release(); + signal.ReleaseObserved(SecondValue); + signal.OnNext(ThirdValue); + signal.OnNext(FourthValue); + await Assert.That(observer.Values.SequenceEqual([FirstValue, SecondValue, ThirdValue, FourthValue])).IsTrue(); + } + + /// A stale occupied count cannot release capacity after the last slot was released. + /// The test operation. + [Test] + public async Task ReleaseWithStaleCountStopsWhenCapacityIsAlreadyFree() + { + using PrioritySemaphoreSignal signal = new(1); + RecordingObserver observer = new(); + using var subscription = signal.Subscribe(observer); + signal.OnNext(FirstValue); + signal.Release(); + signal.ReleaseObserved(1); + signal.OnNext(SecondValue); + signal.OnNext(ThirdValue); + await Assert.That(observer.Values.SequenceEqual([FirstValue, SecondValue])).IsTrue(); + } + /// Error stops the queue and forwards the exact exception. /// A task representing the asynchronous operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs index 4139c9af..db48ebe5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.Scheduling.cs @@ -10,64 +10,90 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies scheduler selection by time-operator aliases. public partial class RxNamesTests { - /// Verifies absolute-time overloads use the default scheduler when no scheduler is supplied. + /// Absolute-time overloads select the thread-pool sequencer when no scheduler is supplied. + /// True to supply a null scheduler explicitly. /// A task representing the asynchronous operation. [Test] - public async Task AbsoluteTimeOperatorsUseDefaultScheduler() + [Arguments(false)] + [Arguments(true)] + public async Task AbsoluteTimeOperatorsUseDefaultScheduler(bool explicitNull) { var dueTime = DateTimeOffset.UnixEpoch; - AwaitableWitness delayedScalar = new(); - AwaitableWitness delayedRange = new(); - AwaitableWitness delayedSubscriptionScalar = new(); - AwaitableWitness delayedSubscriptionRange = new(); - AwaitableWitness delayedExplicitRange = new(); - AwaitableWitness delayedSubscriptionExplicitRange = new(); - AwaitableWitness timeout = new(); - AwaitableWitness explicitTimeout = new(); - const ISequencer? defaultScheduler = null; + IObservable[] sources = [Signal.Emit(One), Signal.Sequence(Two, Two)]; + foreach (var source in sources) + { + var delayed = explicitNull ? source.Delay(dueTime, null) : source.Delay(dueTime); + var delay = await Assert.That(delayed).IsTypeOf>().And.IsNotNull(); + await Assert.That(delay.Scheduler).IsSameReferenceAs(ThreadPoolSequencer.Instance); - using var delayScalarSubscription = Signal.Emit(One) - .Delay(dueTime) - .Subscribe(delayedScalar); - using var delayRangeSubscription = Signal.Sequence(Two, Two) - .Delay(dueTime) - .Subscribe(delayedRange); - using var delayExplicitRangeSubscription = Signal.Sequence(Two, Two) - .Delay(dueTime, defaultScheduler) - .Subscribe(delayedExplicitRange); - using var subscriptionScalarSubscription = Signal.Emit(One) - .DelaySubscription(dueTime) - .Subscribe(delayedSubscriptionScalar); - using var subscriptionRangeSubscription = Signal.Sequence(Two, Two) - .DelaySubscription(dueTime) - .Subscribe(delayedSubscriptionRange); - using var subscriptionExplicitRangeSubscription = Signal.Sequence(Two, Two) - .DelaySubscription(dueTime, defaultScheduler) - .Subscribe(delayedSubscriptionExplicitRange); - using var timeoutSubscription = Signal.Silent() - .Timeout(dueTime) - .Subscribe(timeout); - using var explicitTimeoutSubscription = Signal.Silent() - .Timeout(dueTime, defaultScheduler) - .Subscribe(explicitTimeout); + var delayedSubscription = explicitNull + ? source.DelaySubscription(dueTime, null) + : source.DelaySubscription(dueTime); + var subscription = await Assert.That(delayedSubscription) + .IsTypeOf>().And.IsNotNull(); + await Assert.That(subscription.Scheduler).IsSameReferenceAs(ThreadPoolSequencer.Instance); + } - await delayedScalar.ValueCountReaching(One); - await delayedRange.ValueCountReaching(Two); - await delayedExplicitRange.ValueCountReaching(Two); - await delayedSubscriptionScalar.ValueCountReaching(One); - await delayedSubscriptionRange.ValueCountReaching(Two); - await delayedSubscriptionExplicitRange.ValueCountReaching(Two); - var timedOut = await timeout.FirstError; - var explicitlyTimedOut = await explicitTimeout.FirstError; + var expiring = explicitNull + ? Signal.Silent().Timeout(dueTime, null) + : Signal.Silent().Timeout(dueTime); + var timeout = await Assert.That(expiring).IsTypeOf>().And.IsNotNull(); + await Assert.That(timeout.Scheduler).IsSameReferenceAs(ThreadPoolSequencer.Instance); + } + + /// Absolute delays emit scalar and range values in order, then complete, when the due time arrives. + /// True to delay a range; false to delay a scalar value. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task AbsoluteDelays_ClockReachesDueTime_EmitValuesAndComplete(bool range) + { + VirtualClock clock = new(DateTimeOffset.UnixEpoch); + var dueTime = clock.Now.AddTicks(DueTicks); + var source = range ? Signal.Sequence(Two, Two) : Signal.Emit(One); + RecordingWitness delayed = new(); + RecordingWitness delayedSubscription = new(); + using var delayHandle = source.Delay(dueTime, clock).Subscribe(delayed); + using var subscriptionHandle = source.DelaySubscription(dueTime, clock).Subscribe(delayedSubscription); + + await Assert.That(delayed.Values).IsEmpty(); + await Assert.That(delayedSubscription.Values).IsEmpty(); + await Assert.That(delayed.Completed).IsEqualTo(0); + await Assert.That(delayedSubscription.Completed).IsEqualTo(0); + + clock.AdvanceBy(TimeSpan.FromTicks(DueTicks)); + + int[] expected = range ? [Two, Three] : [One]; + await Assert.That(delayed.Values.SequenceEqual(expected)).IsTrue(); + await Assert.That(delayedSubscription.Values.SequenceEqual(expected)).IsTrue(); + await Assert.That(delayed.Completed).IsEqualTo(1); + await Assert.That(delayedSubscription.Completed).IsEqualTo(1); + await Assert.That(delayed.Errors).IsEmpty(); + await Assert.That(delayedSubscription.Errors).IsEmpty(); + } + + /// An absolute timeout emits one error when the clock reaches its due time. + /// A task representing the asynchronous test. + [Test] + public async Task AbsoluteTimeout_ClockReachesDueTime_EmitsOneError() + { + VirtualClock clock = new(DateTimeOffset.UnixEpoch); + RecordingWitness observer = new(); + using var subscription = Signal.Silent() + .Timeout(clock.Now.AddTicks(DueTicks), clock) + .Subscribe(observer); + + await Assert.That(observer.Errors).IsEmpty(); + clock.AdvanceBy(TimeSpan.FromTicks(DueTicks)); + + await Assert.That(observer.Errors).HasSingleItem(); + await Assert.That(observer.Errors[0]).IsTypeOf(); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); - await Assert.That(delayedScalar.Values.SequenceEqual([One])).IsTrue(); - await Assert.That(delayedRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedSubscriptionScalar.Values.SequenceEqual([One])).IsTrue(); - await Assert.That(delayedSubscriptionRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(delayedSubscriptionExplicitRange.Values.SequenceEqual([Two, Three])).IsTrue(); - await Assert.That(timedOut).IsTypeOf(); - await Assert.That(explicitlyTimedOut).IsTypeOf(); + clock.AdvanceBy(TimeSpan.FromTicks(DueTicks)); + await Assert.That(observer.Errors).HasSingleItem(); } /// Time aliases construct signals when no sequencer is supplied. diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs index 96a7d6ea..a88083d5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/RxNamesTests.cs @@ -857,7 +857,7 @@ await Assert.That(new ResumeSignal(Signal.FromEnumerable(_oneToThree), Sign /// Returns the value unchanged. /// The source value. - /// The value. + /// The same value. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int Identity(int value) => value; diff --git a/src/tests/ReactiveUI.Primitives.Tests/ScheduledItemTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ScheduledItemTests.cs new file mode 100644 index 00000000..51a389a6 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/ScheduledItemTests.cs @@ -0,0 +1,40 @@ +// 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.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests ownership of resources returned by scheduled actions. +public class ScheduledItemTests +{ + /// Cancellation after publication leaves no resource for final cleanup to release again. + /// A task representing the asynchronous test. + [Test] + public async Task CancellationAfterPublication_ReleasesResultOnce() + { + RecordingDisposable result = new(); + ScheduledItem item = new(0, Comparer.Default, _ => result); + item.Invoke(); + item.Dispose(); + item.ReleaseCanceledResult(); + await Assert.That(result.DisposeCount).IsEqualTo(1); + } + + /// Cancellation inside the action releases the result returned afterward. + /// A task representing the asynchronous test. + [Test] + public async Task CancellationBeforePublication_ReleasesResultOnce() + { + RecordingDisposable result = new(); + ScheduledItem item = new(0, Comparer.Default, pending => + { + pending.Dispose(); + return result; + }); + item.Invoke(); + item.Dispose(); + await Assert.That(result.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs index ddfbb345..0ab9d410 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Cancellation.cs @@ -99,15 +99,28 @@ public async Task TaskPoolSkipsWorkCanceledAfterDispatch() public async Task ThreadPoolFinalCancellationCleanupDoesNotReleaseTheResultTwice() { using ManualThreadPool pool = new(); - var releases = 0; + RecordingDisposable result = new(); ThreadPoolSequencer.ScheduledWorkItem item = new( pool.Sequencer, 0, - (_, _) => new ActionDisposable(() => releases++)); + (_, _) => result); item.Execute(); item.Dispose(); item.ReleaseCanceledResult(); - await Assert.That(releases).IsEqualTo(1); + await Assert.That(result.DisposeCount).IsEqualTo(1); await Assert.That(item.IsDisposed).IsTrue(); } + + /// Cancellation after publication leaves no result for final cleanup to release again. + /// A task representing the asynchronous test. + [Test] + public async Task DelegateFinalCancellationCleanupDoesNotReleaseTheResultTwice() + { + RecordingDisposable result = new(); + Sequencer.DelegateWorkItem item = new(Sequencer.Immediate, 0, (_, _) => result); + item.Execute(); + item.Dispose(); + item.ReleaseCanceledResult(); + await Assert.That(result.DisposeCount).IsEqualTo(1); + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs index ee36c617..40be6267 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs @@ -2,6 +2,8 @@ // 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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Signals; namespace ReactiveUI.Primitives.Tests; @@ -60,25 +62,21 @@ public async Task ExpireFailsASilentSequenceWhenTheDelayRuns() await Assert.That(witness.Errors.Count).IsEqualTo(1); } - /// Verifies the sequencer-free Start factories run their work and emit its outcome. + /// The sequencer-free Start factories select the default sequencer without invoking their work. /// A task representing the asynchronous operation. [Test] - public async Task StartWithoutASequencerRunsTheWorkOnTheDefaultSequencer() + public async Task StartWithoutASequencerSelectsTheDefaultSequencer() { - AwaitableWitness functionWitness = new(); - using var functionSubscription = Signal.Start(static () => Two).Subscribe(functionWitness); - await functionWitness.ValueCountReaching(1); - await Assert.That(functionWitness.Values.SequenceEqual([Two])).IsTrue(); + var functionRuns = 0; + var function = await Assert.That(Signal.Start(() => ++functionRuns)).IsTypeOf>().And.IsNotNull(); var actionRuns = 0; - AwaitableWitness actionWitness = new(); - - // A void method group selects Start(Action); a lambda over 'actionRuns++' would bind to Start. void RunAction() => actionRuns++; + var action = await Assert.That(Signal.Start(RunAction)).IsTypeOf().And.IsNotNull(); - using var actionSubscription = Signal.Start(RunAction).Subscribe(actionWitness); - await actionWitness.ValueCountReaching(1); - await Assert.That(actionRuns).IsEqualTo(1); - await Assert.That(actionWitness.Values[0]).IsEqualTo(RxVoid.Default); + await Assert.That(function.Scheduler).IsSameReferenceAs(Sequencer.Default); + await Assert.That(action.Scheduler).IsSameReferenceAs(Sequencer.Default); + await Assert.That(functionRuns).IsEqualTo(0); + await Assert.That(actionRuns).IsEqualTo(0); } /// Each scheduled callback emits the next tick. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs index 20678a05..a8059292 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalTests.cs @@ -752,7 +752,7 @@ private sealed class RecordingWitness : IObserver public int Errors { get; private set; } /// Receives the next value. - /// The value. + /// The observed value. public void OnNext(int value) => Total += value; /// Receives an error. diff --git a/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs index 96974433..4670d9f7 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StartSignalTests.cs @@ -4,10 +4,11 @@ using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; namespace ReactiveUI.Primitives.Tests; -/// Verifies reports whether it must be subscribed on the current thread. +/// Tests scheduler selection and work results for start signals. public sealed class StartSignalTests { /// The value produced by the start function. @@ -32,4 +33,80 @@ public async Task StartSignalOffTheCurrentThreadSequencerDoesNotRequireTheCurren await Assert.That(signal.IsRequiredSubscribeOnCurrentThread()).IsFalse(); } + + /// A default start function runs once, emits its result, and completes. + /// A task representing the asynchronous test. + [Test] + public async Task Run_DefaultFunction_EmitsResultAndCompletes() + { + var runs = 0; + var signal = await Assert.That(Signal.Start(() => + { + runs++; + return ProducedValue; + })).IsTypeOf>().And.IsNotNull(); + RecordingWitness observer = new(); + + signal.Run(observer); + + await Assert.That(runs).IsEqualTo(1); + await Assert.That(observer.Values).HasSingleItem(); + await Assert.That(observer.Values[0]).IsEqualTo(ProducedValue); + await Assert.That(observer.Completed).IsEqualTo(1); + await Assert.That(observer.Errors).IsEmpty(); + } + + /// A default start action runs once, emits its unit value, and completes. + /// A task representing the asynchronous test. + [Test] + public async Task Run_DefaultAction_EmitsUnitAndCompletes() + { + var runs = 0; + void RunAction() => runs++; + var signal = await Assert.That(Signal.Start(RunAction)).IsTypeOf().And.IsNotNull(); + RecordingWitness observer = new(); + + signal.Run(observer); + + await Assert.That(runs).IsEqualTo(1); + await Assert.That(observer.Values).HasSingleItem(); + await Assert.That(observer.Values[0]).IsEqualTo(RxVoid.Default); + await Assert.That(observer.Completed).IsEqualTo(1); + await Assert.That(observer.Errors).IsEmpty(); + } + + /// A failing start function forwards its exception without a value or completion. + /// A task representing the asynchronous test. + [Test] + public async Task Run_FailingFunction_ForwardsError() + { + InvalidOperationException fault = new("function failed"); + var signal = await Assert.That(Signal.Start(() => throw fault)).IsTypeOf>().And.IsNotNull(); + RecordingWitness observer = new(); + + signal.Run(observer); + + await Assert.That(observer.Errors).HasSingleItem(); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(fault); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); + } + + /// A failing start action forwards its exception without a value or completion. + /// A task representing the asynchronous test. + [Test] + public async Task Run_FailingAction_ForwardsError() + { + InvalidOperationException fault = new("action failed"); + void RunAction() => throw fault; + var signal = await Assert.That(Signal.Start(RunAction)).IsTypeOf().And.IsNotNull(); + RecordingWitness observer = new(); + + signal.Run(observer); + + await Assert.That(observer.Errors).HasSingleItem(); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(fault); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SwitchCoordinatorTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SwitchCoordinatorTests.cs new file mode 100644 index 00000000..dfb41f71 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/SwitchCoordinatorTests.cs @@ -0,0 +1,178 @@ +// 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.Primitives.Disposables; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests generation changes and notification ordering in the switch coordinator. +public sealed class SwitchCoordinatorTests +{ + /// The value emitted by the replacement source. + private const int ReplacementValue = 2; + + /// The value emitted by a superseded source. + private const int StaleValue = 3; + + /// Switching preserves ordered delivery while callbacks own the coordinator gate. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchTo_SwitchBetweenValues_HoldsGateDuringDelivery() + { + Signal> outer = new(); + CapturingObservable first = new(); + CapturingObservable second = new(); + List values = []; + LinqExtensions.SwitchCoordinator? coordinator = null; + var ownsGate = true; + using var subscription = outer.SwitchTo().Subscribe(value => + { + ownsGate &= IsHeld(coordinator!.Gate); + values.Add(value); + }); + coordinator = (LinqExtensions.SwitchCoordinator)subscription; + + outer.OnNext(first); + first.Observer!.OnNext(1); + outer.OnNext(second); + first.Observer.OnNext(StaleValue); + second.Observer!.OnNext(ReplacementValue); + + await Assert.That(ownsGate).IsTrue(); + await Assert.That(values.SequenceEqual([1, ReplacementValue])).IsTrue(); + } + + /// A value accepted before a switch is retained, and later values from that generation are dropped. + /// A task representing the asynchronous test. + [Test] + public async Task TryBeginSource_BetweenInnerValues_RejectsPreviousGeneration() + { + RecordingWitness observer = new(); + using LinqExtensions.SwitchCoordinator coordinator = new(observer); + + await Assert.That(coordinator.TryBeginSource(out var first)).IsTrue(); + coordinator.OnNext(first, 1); + await Assert.That(coordinator.TryBeginSource(out var second)).IsTrue(); + coordinator.OnNext(first, StaleValue); + coordinator.OnError(first, new InvalidOperationException("stale")); + coordinator.OnCompleted(first); + coordinator.OnOuterCompleted(); + + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Errors).IsEmpty(); + + coordinator.OnNext(second, ReplacementValue); + coordinator.OnCompleted(second); + + await Assert.That(observer.Values.SequenceEqual([1, ReplacementValue])).IsTrue(); + await Assert.That(observer.Completed).IsEqualTo(1); + } + + /// Completion waits for both sources and rejects subsequent values and source generations. + /// True when the outer source completes before the active inner source. + /// A task representing the asynchronous test. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task OnCompleted_SourceOrder_CompletesOnce(bool outerFirst) + { + RecordingWitness observer = new(); + using LinqExtensions.SwitchCoordinator coordinator = new(observer); + await Assert.That(coordinator.TryBeginSource(out var version)).IsTrue(); + + if (outerFirst) + { + coordinator.OnOuterCompleted(); + } + else + { + coordinator.OnCompleted(version); + } + + await Assert.That(observer.Completed).IsEqualTo(0); + + if (outerFirst) + { + coordinator.OnCompleted(version); + } + else + { + coordinator.OnOuterCompleted(); + } + + coordinator.OnCompleted(version); + coordinator.OnOuterCompleted(); + coordinator.OnNext(version, 1); + coordinator.OnError(version, new InvalidOperationException("late inner")); + coordinator.OnOuterError(new InvalidOperationException("late outer")); + + await Assert.That(coordinator.TryBeginSource(out _)).IsFalse(); + await Assert.That(observer.Completed).IsEqualTo(1); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Errors).IsEmpty(); + } + + /// The first source error is forwarded and prevents subsequent errors, values, and completion. + /// True when the outer source reports the first error. + /// A task representing the asynchronous test. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task OnError_SourceOrder_ForwardsFirstError(bool outerFirst) + { + RecordingWitness observer = new(); + using LinqExtensions.SwitchCoordinator coordinator = new(observer); + await Assert.That(coordinator.TryBeginSource(out var version)).IsTrue(); + InvalidOperationException first = new("first"); + InvalidOperationException second = new("second"); + + if (outerFirst) + { + coordinator.OnOuterError(first); + coordinator.OnError(version, second); + } + else + { + coordinator.OnError(version, first); + coordinator.OnOuterError(second); + } + + coordinator.OnNext(version, 1); + coordinator.OnCompleted(version); + coordinator.OnOuterCompleted(); + + await Assert.That(coordinator.TryBeginSource(out _)).IsFalse(); + await Assert.That(observer.Errors).HasSingleItem(); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(first); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); + } + + /// Reports whether the calling thread owns the gate. + /// The coordinator synchronization gate. + /// True when the calling thread owns the gate; otherwise, false. + private static bool IsHeld(Lock gate) + { +#if NET9_0_OR_GREATER + return gate.IsHeldByCurrentThread; +#else + return Monitor.IsEntered(gate); +#endif + } + + /// Retains the inner observer for notifications after a source switch. + private sealed class CapturingObservable : IObservable + { + /// Gets the observer captured by subscription. + public IObserver? Observer { get; private set; } + + /// + public IDisposable Subscribe(IObserver observer) + { + Observer = observer; + return EmptyDisposable.Instance; + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs index 57298277..02a78cfa 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SwitchWitnessTests.cs @@ -2,7 +2,6 @@ // 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.Runtime.ExceptionServices; using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Disposables; using ReactiveUI.Primitives.Signals; @@ -36,50 +35,6 @@ public async Task SwitchWitnessSuppressesDuplicateCompletionFromCurrentInner() await Assert.That(observer.Completed).IsEqualTo(One); } - /// Verifies the public switch operator serializes a source switch against an in-flight inner delivery. - /// A task representing the asynchronous operation. - [Test] - public async Task SwitchToDoesNotEnterObserverConcurrentlyWhenSwitchingInnerSources() -{ - Signal> outer = new(); - CapturingObservable first = new(); - CapturingObservable second = new(); - using GatedObserver observer = new(); - using var subscription = outer.SwitchTo().Subscribe(observer); - var coordinator = (LinqExtensions.SwitchCoordinator)subscription; - outer.OnNext(first); - var firstDelivery = Task.Run(() => first.Observer!.OnNext(One)); - observer.FirstEntered.Wait(); - bool entered; - try - { -#if NET9_0_OR_GREATER - entered = coordinator.Gate.TryEnter(); - if (entered) - { - coordinator.Gate.Exit(); - } -#else - entered = Monitor.TryEnter(coordinator.Gate); - if (entered) - { - Monitor.Exit(coordinator.Gate); - } -#endif - } - finally - { - observer.ReleaseFirst.Set(); - } - - await firstDelivery; - await Assert.That(entered).IsFalse(); - outer.OnNext(second); - second.Observer!.OnNext(Two); - await Assert.That(observer.ConcurrentOnNext).IsFalse(); - await Assert.That(observer.Values).IsEqualTo(Two); - } - /// Verifies switching inner sources forwards only the latest source's values. /// A task representing the asynchronous operation. [Test] @@ -328,64 +283,4 @@ public IDisposable Subscribe(IObserver observer) return new ActionDisposable(() => Disposed = true); } } - - /// Observer that blocks inside its first value callback so concurrent re-entry is detected. - private sealed class GatedObserver : IObserver, IDisposable - { - /// Tracks how many threads are currently inside . - private int _inOnNext; - - /// Counts forwarded values. - private int _values; - - /// Set when more than one thread is inside at once. - private int _concurrent; - - /// Gets the event set when the first call is entered. - public ManualResetEventSlim FirstEntered { get; } = new(); - - /// Gets the event released by the test to unblock the first call. - public ManualResetEventSlim ReleaseFirst { get; } = new(); - - /// Gets the number of forwarded values. - public int Values => Volatile.Read(ref _values); - - /// Gets a value indicating whether was entered concurrently. - public bool ConcurrentOnNext => Volatile.Read(ref _concurrent) != 0; - - /// - public void OnCompleted() - { - } - - /// - public void OnError(Exception error) - { - } - - /// - public void OnNext(int value) -{ - if (Interlocked.Increment(ref _inOnNext) != 1) - { - _ = Interlocked.Exchange(ref _concurrent, 1); - } - - var index = Interlocked.Increment(ref _values); - if (index == 1) - { - FirstEntered.Set(); - ReleaseFirst.Wait(); - } - - _ = Interlocked.Decrement(ref _inOnNext); - } - - /// - public void Dispose() - { - FirstEntered.Dispose(); - ReleaseFirst.Dispose(); - } - } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs index d94b3b26..da036cc5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/TaskPoolSequencerTests.cs @@ -111,4 +111,44 @@ public async Task TaskPoolDelayLargerThanIntMaxValue() using var scheduled = sequencer.Schedule(dueTime, static () => { }); await Assert.That(scheduled).IsNotNull(); } + + /// Cancellation between the delay callback and task dispatch suppresses the action. + /// A task representing the asynchronous test. + [Test] + public async Task Schedule_CancelAfterDelayBeforeDispatch_DropsWork() + { + using ManualThreadPool delays = new(); + ManualTaskScheduler tasks = new(); + TaskPoolSequencer sequencer = new(new(tasks), delays.Sequencer); + StrongBox runs = new(); + var scheduled = sequencer.Schedule(runs, TimeSpan.FromSeconds(1), static state => state.Value++); + + tasks.RunPending(); + await Assert.That(runs.Value).IsEqualTo(0); + delays.RunDue(Sequencer.ToTimestampDelta(TimeSpan.FromSeconds(1))); + scheduled.Dispose(); + tasks.RunPending(); + await Assert.That(runs.Value).IsEqualTo(0); + } + + /// Work remains delayed until the supplied monotonic clock reaches its due timestamp. + /// A task representing the asynchronous test. + [Test] + public async Task Schedule_AdvanceClockToDue_DispatchesOnce() + { + using ManualThreadPool delays = new(); + ManualTaskScheduler tasks = new(); + TaskPoolSequencer sequencer = new(new(tasks), delays.Sequencer); + StrongBox runs = new(); + using var scheduled = sequencer.Schedule(runs, TimeSpan.FromSeconds(1), static state => state.Value++); + var due = Sequencer.ToTimestampDelta(TimeSpan.FromSeconds(1)); + delays.RunDue(due - 1); + tasks.RunPending(); + await Assert.That(runs.Value).IsEqualTo(0); + delays.RunDue(due); + await Assert.That(sequencer.Timestamp).IsEqualTo(due); + await Assert.That(runs.Value).IsEqualTo(0); + tasks.RunPending(); + await Assert.That(runs.Value).IsEqualTo(1); + } } diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs index 405cf6ec..65079628 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs @@ -3,70 +3,221 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Runtime.CompilerServices; using ReactiveUI.Primitives.Reactive.Concurrency; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.WinForms.Reactive.Tests; -/// Tests scheduler dispatch on a dedicated Windows Forms STA thread. +/// Tests control dispatch with manually delivered callbacks and handle notifications. public sealed class ControlSequencerTests { - /// Verifies the constructor rejects a null control. - /// A representing the asynchronous operation. + /// The second value in a scheduled batch. + private const int SecondValue = 2; + + /// Constructor validation rejects a missing control. + /// The test operation. [Test] public async Task ConstructorRejectsNullControl() => await Assert.That(static () => new ControlSequencer(null!)).ThrowsExactly(); - /// Verifies immediate work is posted to and executed on the control's UI thread. - /// A representing the asynchronous operation. + /// Work rejected before handle creation is retried in order when the handle becomes ready. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnControlThread() + public async Task HandleCreatedRetriesQueuedWorkAndSkipsCancellation() { - using var harness = new ControlHarness(); - var scheduler = new ControlSequencer(harness.Control); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var control = CreateControl(); + Queue drains = new(); + var ready = false; + ControlSequencer scheduler = new( + control, + drain => + { + if (!ready) + { + return false; + } - _ = scheduler.Schedule(() => completion.TrySetResult(Environment.CurrentManagedThreadId)); + drains.Enqueue(drain); + return true; + }, + null); + List values = []; + using var first = scheduler.Schedule(() => values.Add(1)); + var cancelled = scheduler.Schedule(() => values.Add(0)); + using var second = scheduler.Schedule(TimeSpan.Zero, () => values.Add(SecondValue)); + cancelled.Dispose(); + await Assert.That(scheduler.Control).IsSameReferenceAs(control); + await Assert.That(drains).IsEmpty(); + await Assert.That(values).IsEmpty(); + ready = true; + scheduler.OnHandleCreated(control, EventArgs.Empty); + await Assert.That(drains).Count().IsEqualTo(1); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); + scheduler.OnHandleCreated(control, EventArgs.Empty); + await Assert.That(drains).IsEmpty(); + } - var ranOnThreadId = await completion.Task; - await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); + /// A missing handle rejects a post before invoking the native dispatcher. + /// The test operation. + [Test] + public async Task TryPostWithoutAHandleDoesNotInvoke() + { + using var control = CreateControl(); + var accepted = ControlSequencer.TryPost( + control, + false, + static () => { }, + static (_, _) => throw new InvalidOperationException("Unexpected dispatch.")); + await Assert.That(accepted).IsFalse(); } - /// Owns the control and its STA message loop. - private sealed class ControlHarness : IDisposable + /// A live handle forwards exactly the supplied callback. + /// The test operation. + [Test] + public async Task TryPostWithAHandleForwardsTheCallback() { - /// The thread running the Windows Forms message loop. - private readonly Thread _thread; + using var control = CreateControl(); + var calls = 0; + Action callback = () => calls++; + Action? posted = null; + var accepted = ControlSequencer.TryPost(control, true, callback, (_, drain) => posted = drain); + await Assert.That(accepted).IsTrue(); + await Assert.That(posted).IsSameReferenceAs(callback); + await Assert.That(calls).IsEqualTo(0); + posted!(); + await Assert.That(calls).IsEqualTo(1); + } + + /// A disappeared handle rejects the post while disposal propagates the failure. + /// Whether disposal occurs during dispatch. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TryPostHandlesDispatcherFailureAccordingToDisposal(bool dispose) + { + using var control = CreateControl(); + InvalidOperationException failure = new("Handle lost."); + bool Post() => ControlSequencer.TryPost( + control, + true, + static () => { }, + (target, _) => + { + if (dispose) + { + target.Dispose(); + } - /// Initializes a new instance of the class with a created control handle. - public ControlHarness() + throw failure; + }); + if (dispose) { - using var ready = new ManualResetEventSlim(false); - _thread = new(() => + var observed = await Assert.That(Post).ThrowsExactly(); + await Assert.That(observed).IsSameReferenceAs(failure); + } + else + { + await Assert.That(Post()).IsFalse(); + } + } + + /// Disposed controls reject work before dispatch. + /// The test operation. + [Test] + public async Task TryPostAfterDisposalThrows() + { + var control = CreateControl(); + control.Dispose(); + await Assert.That(() => ControlSequencer.TryPost(control, true, static () => { }, static (_, _) => { })) + .ThrowsExactly(); + } + + /// Disposal cancels a delayed timer and suppresses even a late callback. + /// Whether to cancel before delivery. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel) + { + using var control = CreateControl(); + Queue drains = new(); + ManualDelayScheduler delayed = new(); + ControlSequencer scheduler = new( + control, + drain => { - Control = new(); - _ = Control.Handle; // Force handle creation so BeginInvoke can marshal work. - ThreadId = Environment.CurrentManagedThreadId; - ready.Set(); - Application.Run(); - }) { IsBackground = true, Name = "WinFormsControlHarness" }; - - _thread.SetApartmentState(ApartmentState.STA); - _thread.Start(); - ready.Wait(); + drains.Enqueue(drain); + return true; + }, + delayed); + var calls = 0; + var delay = TimeSpan.FromSeconds(1); + var handle = scheduler.Schedule(delay, () => calls++); + var pending = delayed.Callbacks.Dequeue(); + await Assert.That(pending.Delay).IsEqualTo(delay); + await Assert.That(calls).IsEqualTo(0); + if (cancel) + { + handle.Dispose(); + } + + pending.Callback(); + drains.Dequeue()(); + await Assert.That(pending.Cancellation.IsDisposed).IsEqualTo(cancel); + await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); + if (cancel) + { + return; + } + + handle.Dispose(); + } + + /// Creates a control without changing the caller's synchronization context. + /// The control. + private static Control CreateControl() + { + var previous = SynchronizationContext.Current; + try + { + return new(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); } + } + + /// Retains delayed actions and permits explicit late delivery after cancellation. + private sealed class ManualDelayScheduler : IScheduler + { + /// Gets pending callbacks and their cancellation handles. + public Queue<(Action Callback, TimeSpan Delay, SingleAssignmentDisposable Cancellation)> Callbacks { get; } = new(); - /// Gets the hosted control. - public Control Control { get; private set; } = null!; + /// + public DateTimeOffset Now => DateTimeOffset.UnixEpoch; - /// Gets the managed thread id the control runs on. - public int ThreadId { get; private set; } + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDisposable Schedule(TState state, Func action) => + Schedule(state, TimeSpan.Zero, action); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => + Schedule(state, dueTime - Now, action); /// - public void Dispose() + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) { - _ = Control.BeginInvoke(Application.ExitThread); - _thread.Join(); - Control.Dispose(); + SingleAssignmentDisposable cancellation = new(); + Callbacks.Enqueue((() => cancellation.Disposable = action(this, state), dueTime, cancellation)); + return cancellation; } } } diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs index e921d343..ca1b0fee 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs @@ -3,85 +3,193 @@ // See the LICENSE file in the project root for full license information. using System.Runtime.CompilerServices; +using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.WinForms.Tests; -/// Tests control dispatch on a dedicated Windows Forms STA thread. +/// Tests control dispatch with manually delivered callbacks and handle notifications. public sealed class ControlSequencerTests { - /// Verifies the constructor rejects a null control. - /// A representing the asynchronous operation. + /// The second value in a scheduled batch. + private const int SecondValue = 2; + + /// Constructor validation rejects a missing control. + /// The test operation. [Test] public async Task ConstructorRejectsNullControl() => await Assert.That(static () => new ControlSequencer(null!)).ThrowsExactly(); - /// Verifies immediate work is posted to and executed on the control's UI thread. - /// A representing the asynchronous operation. + /// Work rejected before handle creation is retried in order when the handle becomes ready. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnControlThread() + public async Task HandleCreatedRetriesQueuedWorkAndSkipsCancellation() { - using var harness = new ControlHarness(); - var sequencer = new ControlSequencer(harness.Control); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var control = CreateControl(); + Queue drains = new(); + var ready = false; + ControlSequencer sequencer = new( + control, + drain => + { + if (!ready) + { + return false; + } - sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId))); + drains.Enqueue(drain); + return true; + }, + null); + List values = []; + RecordingWorkItem cancelled = new(() => values.Add(0)); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(1))); + sequencer.Schedule(cancelled); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(SecondValue)), 0); + cancelled.Dispose(); + await Assert.That(sequencer.Control).IsSameReferenceAs(control); + await Assert.That(drains).IsEmpty(); + await Assert.That(values).IsEmpty(); + ready = true; + sequencer.OnHandleCreated(control, EventArgs.Empty); + await Assert.That(drains).Count().IsEqualTo(1); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); + sequencer.OnHandleCreated(control, EventArgs.Empty); + await Assert.That(drains).IsEmpty(); + } - var ranOnThreadId = await completion.Task; - await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); + /// A missing handle rejects a post before invoking the native dispatcher. + /// The test operation. + [Test] + public async Task TryPostWithoutAHandleDoesNotInvoke() + { + using var control = CreateControl(); + var accepted = ControlSequencer.TryPost( + control, + false, + static () => { }, + static (_, _) => throw new InvalidOperationException("Unexpected dispatch.")); + await Assert.That(accepted).IsFalse(); + } + + /// A live handle forwards exactly the supplied callback. + /// The test operation. + [Test] + public async Task TryPostWithAHandleForwardsTheCallback() + { + using var control = CreateControl(); + var calls = 0; + Action callback = () => calls++; + Action? posted = null; + var accepted = ControlSequencer.TryPost(control, true, callback, (_, drain) => posted = drain); + await Assert.That(accepted).IsTrue(); + await Assert.That(posted).IsSameReferenceAs(callback); + await Assert.That(calls).IsEqualTo(0); + posted!(); + await Assert.That(calls).IsEqualTo(1); } - /// Work item that invokes a delegate when executed. - private sealed class DelegateWorkItem : IWorkItem + /// A disappeared handle rejects the post while disposal propagates the failure. + /// Whether disposal occurs during dispatch. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TryPostHandlesDispatcherFailureAccordingToDisposal(bool dispose) { - /// The action to run on execution. - private readonly Action _action; + using var control = CreateControl(); + InvalidOperationException failure = new("Handle lost."); + bool Post() => ControlSequencer.TryPost( + control, + true, + static () => { }, + (target, _) => + { + if (dispose) + { + target.Dispose(); + } - /// Initializes a new instance of the class. - /// The action to run on execution. - public DelegateWorkItem(Action action) => _action = action; + throw failure; + }); + if (dispose) + { + var observed = await Assert.That(Post).ThrowsExactly(); + await Assert.That(observed).IsSameReferenceAs(failure); + } + else + { + await Assert.That(Post()).IsFalse(); + } + } - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Execute() => _action(); + /// Disposed controls reject work before dispatch. + /// The test operation. + [Test] + public async Task TryPostAfterDisposalThrows() + { + var control = CreateControl(); + control.Dispose(); + await Assert.That(() => ControlSequencer.TryPost(control, true, static () => { }, static (_, _) => { })) + .ThrowsExactly(); } - /// Owns the control and its STA message loop. - private sealed class ControlHarness : IDisposable + /// Delayed work waits for its callback and skips cancellation. + /// Whether to cancel before delivery. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleWaitsForItsCallback(bool cancel) { - /// The thread running the Windows Forms message loop. - private readonly Thread _thread; + using var control = CreateControl(); + Queue<(IWorkItem Item, long Due)> delayed = new(); + ControlSequencer sequencer = new(control, static _ => false, (item, due) => delayed.Enqueue((item, due))); + var calls = 0; + RecordingWorkItem item = new(() => calls++); + sequencer.Schedule(item, long.MaxValue); + await Assert.That(calls).IsEqualTo(0); + var pending = delayed.Dequeue(); + await Assert.That(pending.Due).IsEqualTo(long.MaxValue); + if (cancel) + { + item.Dispose(); + } - /// Initializes a new instance of the class with a created control handle. - public ControlHarness() + DispatchSequencerState.RunIfActive(pending.Item); + await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); + } + + /// Creates a control without changing the caller's synchronization context. + /// The control. + private static Control CreateControl() + { + var previous = SynchronizationContext.Current; + try { - using var ready = new ManualResetEventSlim(false); - _thread = new(() => - { - Control = new(); - _ = Control.Handle; // Force handle creation so BeginInvoke can marshal work. - ThreadId = Environment.CurrentManagedThreadId; - ready.Set(); - Application.Run(); - }) { IsBackground = true, Name = "WinFormsControlHarness" }; - - _thread.SetApartmentState(ApartmentState.STA); - _thread.Start(); - ready.Wait(); + return new(); } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + } - /// Gets the hosted control. - public Control Control { get; private set; } = null!; + /// Records execution and supports cancellation. + /// The callback to run. + private sealed class RecordingWorkItem(Action action) : IWorkItem, IsDisposed + { + /// + public bool IsDisposed { get; private set; } - /// Gets the managed thread id the control runs on. - public int ThreadId { get; private set; } + /// + public void Dispose() => IsDisposed = true; /// - public void Dispose() - { - _ = Control.BeginInvoke(Application.ExitThread); - _thread.Join(); - Control.Dispose(); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Execute() => action(); } } diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs index 9006f733..bf09a77b 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs @@ -3,84 +3,169 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Concurrency; +using System.Reactive.Disposables; using Microsoft.UI.Dispatching; using ReactiveUI.Primitives.Reactive.Concurrency; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.WinUI.Reactive.Tests; -/// Tests scheduler execution on a dedicated WinUI queue thread. +/// Tests dispatcher queue batching, rejection, and cancellation through controlled callbacks. public sealed class DispatcherQueueSequencerTests { - /// Verifies the constructor rejects a null dispatcher queue. - /// A representing the asynchronous operation. + /// The second value in a scheduled batch. + private const int SecondValue = 2; + + /// Expected values after both queued items run. + private static readonly int[] BatchValues = [1, SecondValue]; + + /// Expected values before the reentrant batch runs. + private static readonly int[] FirstBatchValues = [1]; + + /// Public constructors reject a missing dispatcher queue. + /// The test operation. [Test] - public async Task ConstructorRejectsNullDispatcherQueue() => + public async Task ConstructorRejectsNullDispatcherQueue() + { await Assert.That(static () => new DispatcherQueueSequencer(null!)).ThrowsExactly(); + await Assert.That(static () => new DispatcherQueueSequencer(null!, DispatcherQueuePriority.High)) + .ThrowsExactly(); + } - /// Verifies a stopped dispatcher queue rejects scheduled work. - /// A task representing the asynchronous test operation. + /// Queued actions preserve order and cancellation until the drain runs. + /// The test operation. [Test] - public async Task SchedulingAfterQueueShutdownThrows() + public async Task ScheduleCoalescesOrderedWorkAndSkipsCancellation() { - var harness = new DispatcherQueueHarness(); - DispatcherQueueSequencer scheduler = new(harness.DispatcherQueue); - await harness.DisposeAsync(); - var executed = false; - await Assert.That(() => scheduler.Schedule(() => executed = true)) - .ThrowsExactly(); - await Assert.That(() => scheduler.Schedule(() => executed = true)) - .ThrowsExactly(); - await Assert.That(executed).IsFalse(); + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + List values = []; + using var first = scheduler.Schedule(() => values.Add(1)); + var cancelled = scheduler.Schedule(() => values.Add(0)); + using var second = scheduler.Schedule(TimeSpan.Zero, () => values.Add(SecondValue)); + cancelled.Dispose(); + await Assert.That(values).IsEmpty(); + await Assert.That(dispatcher.Drains).Count().IsEqualTo(1); + await Assert.That(dispatcher.LastPriority).IsEqualTo(DispatcherQueuePriority.High); + await Assert.That(scheduler.Priority).IsEqualTo(DispatcherQueuePriority.High); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(BatchValues, EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies immediate work is enqueued to and executed on the dispatcher-queue thread. - /// A representing the asynchronous operation. + /// A rejected post retains queued actions and reuses its cached callback on retry. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnQueueThread() + public async Task RejectedPostCanRetryWithoutLosingQueuedWork() { - await using var harness = new DispatcherQueueHarness(); - var scheduler = new DispatcherQueueSequencer(harness.DispatcherQueue); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _ = scheduler.Schedule(() => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess)); + ManualDispatcher dispatcher = new() { AcceptsPosts = false }; + var scheduler = dispatcher.Create(); + List values = []; + await Assert.That(() => scheduler.Schedule(() => values.Add(1))) + .ThrowsExactly(); + await Assert.That(values).IsEmpty(); + var rejectedHandler = dispatcher.LastHandler; + dispatcher.AcceptsPosts = true; + using var next = scheduler.Schedule(() => values.Add(SecondValue)); + await Assert.That(dispatcher.LastHandler).IsSameReferenceAs(rejectedHandler); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(BatchValues, EqualityComparer.Default, CollectionOrdering.Matching); + } - var ranOnQueueThread = await completion.Task; - await Assert.That(ranOnQueueThread).IsTrue(); + /// Actions queued from inside a callback wait for a later drain. + /// The test operation. + [Test] + public async Task ReentrantScheduleRunsInTheNextBatch() + { + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + List values = []; + using var first = scheduler.Schedule(() => + { + values.Add(1); + _ = scheduler.Schedule(() => values.Add(SecondValue)); + }); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(FirstBatchValues, EqualityComparer.Default, CollectionOrdering.Matching); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(BatchValues, EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies due work executes on the dispatcher queue thread. - /// A representing the asynchronous operation. + /// Timer cancellation suppresses a callback even when it is delivered late. + /// Whether cancellation precedes delivery. + /// The test operation. [Test] - public async Task DueScheduleExecutesOnQueueThread() + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel) { - await using var harness = new DispatcherQueueHarness(); - var scheduler = new DispatcherQueueSequencer(harness.DispatcherQueue); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ManualDispatcher dispatcher = new(); + var scheduler = dispatcher.Create(); + var calls = 0; + var delay = TimeSpan.FromSeconds(1); + var handle = scheduler.Schedule(delay, () => calls++); + var pending = dispatcher.Delays.Dequeue(); + await Assert.That(calls).IsEqualTo(0); + await Assert.That(dispatcher.Drains).IsEmpty(); + await Assert.That(pending.Delay).IsEqualTo(delay); + if (cancel) + { + handle.Dispose(); + } - _ = scheduler.Schedule(TimeSpan.Zero, () => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess)); + pending.Callback(); + await Assert.That(pending.Cancellation.IsDisposed).IsEqualTo(cancel); + await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); + if (cancel) + { + return; + } - var ranOnQueueThread = await completion.Task; - await Assert.That(ranOnQueueThread).IsTrue(); + handle.Dispose(); } - /// Hosts a WinUI on a dedicated thread and shuts the queue down on disposal. - private sealed class DispatcherQueueHarness : IAsyncDisposable + /// Retains callbacks until they are explicitly delivered. + private sealed class ManualDispatcher { - /// The controller owning the dedicated dispatcher-queue thread. - private readonly DispatcherQueueController _controller; + /// Gets queued drains. + public Queue Drains { get; } = new(); - /// Initializes a new instance of the class. - public DispatcherQueueHarness() - { - _controller = DispatcherQueueController.CreateOnDedicatedThread(); - DispatcherQueue = _controller.DispatcherQueue; - } + /// Gets delayed callbacks and their cancellation handles. + public Queue<(Action Callback, TimeSpan Delay, BooleanDisposable Cancellation)> Delays { get; } = new(); - /// Gets the hosted dispatcher queue. - public DispatcherQueue DispatcherQueue { get; } + /// Gets or sets whether enqueue attempts succeed. + public bool AcceptsPosts { get; set; } = true; - /// - /// A task representing the asynchronous operation. - public async ValueTask DisposeAsync() => await _controller.ShutdownQueueAsync().AsTask(); + /// Gets the most recently posted handler. + public DispatcherQueueHandler? LastHandler { get; private set; } + + /// Gets the priority of the most recent post. + public DispatcherQueuePriority LastPriority { get; private set; } + + /// Creates a scheduler using the retained callbacks. + /// The scheduler. + public DispatcherQueueSequencer Create() => + new(DispatcherQueuePriority.High, TryEnqueue, (callback, delay) => + { + BooleanDisposable cancellation = new(); + Delays.Enqueue((callback, delay, cancellation)); + return cancellation; + }); + + /// Accepts or rejects a drain without executing it. + /// The requested priority. + /// The drain callback. + /// Whether the callback was accepted. + private bool TryEnqueue(DispatcherQueuePriority priority, DispatcherQueueHandler handler) + { + LastPriority = priority; + LastHandler = handler; + if (!AcceptsPosts) + { + return false; + } + + Drains.Enqueue(handler); + return true; + } } } diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/ReactiveUI.Primitives.WinUI.Reactive.Tests.csproj b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/ReactiveUI.Primitives.WinUI.Reactive.Tests.csproj index 7089b97e..b4dd5b9a 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/ReactiveUI.Primitives.WinUI.Reactive.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/ReactiveUI.Primitives.WinUI.Reactive.Tests.csproj @@ -1,10 +1,6 @@ - + $(NetTargetFrameworks) $(WinUITargetFrameworks) @@ -16,7 +12,6 @@ - @@ -26,7 +21,11 @@ true None - true + true + false + false + false + false false win-x64 $(DefineConstants);WINDOWS diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs index fee1e84b..658a1383 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs @@ -4,110 +4,175 @@ using System.Runtime.CompilerServices; using Microsoft.UI.Dispatching; +using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.WinUI.Tests; -/// Tests dispatcher execution on a dedicated WinUI queue thread. +/// Tests dispatcher queue batching, rejection, and cancellation through controlled callbacks. public sealed class DispatcherQueueSequencerTests { - /// Verifies the constructor rejects a null dispatcher queue. - /// A representing the asynchronous operation. + /// The second value in a scheduled batch. + private const int SecondValue = 2; + + /// Expected values after both queued items run. + private static readonly int[] BatchValues = [1, SecondValue]; + + /// Expected values before the reentrant batch runs. + private static readonly int[] FirstBatchValues = [1]; + + /// Public constructors reject a missing dispatcher queue. + /// The test operation. [Test] - public async Task ConstructorRejectsNullDispatcherQueue() => + public async Task ConstructorRejectsNullDispatcherQueue() + { await Assert.That(static () => new DispatcherQueueSequencer(null!)).ThrowsExactly(); + await Assert.That(static () => new DispatcherQueueSequencer(null!, DispatcherQueuePriority.High)) + .ThrowsExactly(); + } - /// Verifies the clock uses UTC and debugger text identifies the sequencer. - /// A task representing the asynchronous test operation. + /// Queued work preserves order and cancellation until the drain runs. + /// The test operation. [Test] - public async Task ClockUsesUtcAndDebuggerTextIdentifiesSequencer() + public async Task ScheduleCoalescesOrderedWorkAndSkipsCancellation() { - await using var harness = new DispatcherQueueHarness(); - DispatcherQueueSequencer sequencer = new(harness.DispatcherQueue); + ManualDispatcher dispatcher = new(); + var sequencer = dispatcher.Create(); + List values = []; + RecordingWorkItem cancelled = new(() => values.Add(0)); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(1))); + sequencer.Schedule(cancelled); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(SecondValue)), 0); + cancelled.Dispose(); + await Assert.That(values).IsEmpty(); + await Assert.That(dispatcher.Drains).Count().IsEqualTo(1); + await Assert.That(dispatcher.LastPriority).IsEqualTo(DispatcherQueuePriority.High); + await Assert.That(sequencer.Priority).IsEqualTo(DispatcherQueuePriority.High); await Assert.That(sequencer.Now.Offset).IsEqualTo(TimeSpan.Zero); await Assert.That(sequencer.DebuggerDisplay).IsEqualTo(typeof(DispatcherQueueSequencer).FullName); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(BatchValues, EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies a stopped dispatcher queue rejects scheduled work. - /// A task representing the asynchronous test operation. + /// A rejected post retains queued work and reuses its cached callback on retry. + /// The test operation. [Test] - public async Task SchedulingAfterQueueShutdownThrows() + public async Task RejectedPostCanRetryWithoutLosingQueuedWork() { - var harness = new DispatcherQueueHarness(); - DispatcherQueueSequencer sequencer = new(harness.DispatcherQueue); - await harness.DisposeAsync(); - var executed = false; - await Assert.That(() => sequencer.Schedule(new DelegateWorkItem(() => executed = true))) - .ThrowsExactly(); - await Assert.That(() => sequencer.Schedule(new DelegateWorkItem(() => executed = true))) + ManualDispatcher dispatcher = new() { AcceptsPosts = false }; + var sequencer = dispatcher.Create(); + List values = []; + await Assert.That(() => sequencer.Schedule(new RecordingWorkItem(() => values.Add(1)))) .ThrowsExactly(); - await Assert.That(executed).IsFalse(); + await Assert.That(values).IsEmpty(); + var rejectedHandler = dispatcher.LastHandler; + dispatcher.AcceptsPosts = true; + sequencer.Schedule(new RecordingWorkItem(() => values.Add(SecondValue))); + await Assert.That(dispatcher.LastHandler).IsSameReferenceAs(rejectedHandler); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(BatchValues, EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies immediate work is enqueued to and executed on the dispatcher-queue thread. - /// A representing the asynchronous operation. + /// Work queued from inside a callback waits for a later drain. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnQueueThread() + public async Task ReentrantScheduleRunsInTheNextBatch() { - await using var harness = new DispatcherQueueHarness(); - var sequencer = new DispatcherQueueSequencer(harness.DispatcherQueue); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess))); - - var ranOnQueueThread = await completion.Task; - await Assert.That(ranOnQueueThread).IsTrue(); + ManualDispatcher dispatcher = new(); + var sequencer = dispatcher.Create(); + List values = []; + sequencer.Schedule(new RecordingWorkItem(() => + { + values.Add(1); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(SecondValue))); + })); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(FirstBatchValues, EqualityComparer.Default, CollectionOrdering.Matching); + dispatcher.Drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo(BatchValues, EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies due work executes on the dispatcher queue thread. - /// A representing the asynchronous operation. + /// Delayed callbacks retain their due time and honor cancellation. + /// Whether cancellation precedes delivery. + /// The test operation. [Test] - public async Task DueScheduleExecutesOnQueueThread() + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleWaitsForItsCallback(bool cancel) { - await using var harness = new DispatcherQueueHarness(); - var sequencer = new DispatcherQueueSequencer(harness.DispatcherQueue); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var due = sequencer.Timestamp; - sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(harness.DispatcherQueue.HasThreadAccess)), due); + ManualDispatcher dispatcher = new(); + var sequencer = dispatcher.Create(); + var calls = 0; + RecordingWorkItem item = new(() => calls++); + sequencer.Schedule(item, long.MaxValue); + await Assert.That(calls).IsEqualTo(0); + await Assert.That(dispatcher.Drains).IsEmpty(); + var pending = dispatcher.Delays.Dequeue(); + await Assert.That(pending.Due).IsEqualTo(long.MaxValue); + if (cancel) + { + item.Dispose(); + } - var ranOnQueueThread = await completion.Task; - await Assert.That(ranOnQueueThread).IsTrue(); + DispatchSequencerState.RunIfActive(pending.Item); + await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); } - /// Work item that invokes a delegate when executed. - private sealed class DelegateWorkItem : IWorkItem + /// Retains callbacks until they are explicitly delivered. + private sealed class ManualDispatcher { - /// The action to run on execution. - private readonly Action _action; + /// Gets the queued drains. + public Queue Drains { get; } = new(); - /// Initializes a new instance of the class. - /// The action to run on execution. - public DelegateWorkItem(Action action) => _action = action; + /// Gets delayed work and requested timestamps. + public Queue<(IWorkItem Item, long Due)> Delays { get; } = new(); - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Execute() => _action(); - } + /// Gets or sets whether enqueue attempts succeed. + public bool AcceptsPosts { get; set; } = true; - /// Hosts a WinUI on a dedicated thread and shuts the queue down on disposal. - private sealed class DispatcherQueueHarness : IAsyncDisposable - { - /// The controller owning the dedicated dispatcher-queue thread. - private readonly DispatcherQueueController _controller; + /// Gets the most recently posted handler. + public DispatcherQueueHandler? LastHandler { get; private set; } + + /// Gets the priority of the most recent post. + public DispatcherQueuePriority LastPriority { get; private set; } + + /// Creates a sequencer using the retained callbacks. + /// The sequencer. + public DispatcherQueueSequencer Create() => + new(DispatcherQueuePriority.High, TryEnqueue, (item, due) => Delays.Enqueue((item, due))); - /// Initializes a new instance of the class. - public DispatcherQueueHarness() + /// Accepts or rejects a drain without executing it. + /// The requested priority. + /// The drain callback. + /// Whether the callback was accepted. + private bool TryEnqueue(DispatcherQueuePriority priority, DispatcherQueueHandler handler) { - _controller = DispatcherQueueController.CreateOnDedicatedThread(); - DispatcherQueue = _controller.DispatcherQueue; + LastPriority = priority; + LastHandler = handler; + if (!AcceptsPosts) + { + return false; + } + + Drains.Enqueue(handler); + return true; } + } + + /// Records execution and supports cancellation. + /// The callback to run. + private sealed class RecordingWorkItem(Action action) : IWorkItem, IsDisposed + { + /// + public bool IsDisposed { get; private set; } - /// Gets the hosted dispatcher queue. - public DispatcherQueue DispatcherQueue { get; } + /// + public void Dispose() => IsDisposed = true; /// - /// A task representing the asynchronous operation. - public async ValueTask DisposeAsync() => await _controller.ShutdownQueueAsync().AsTask(); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Execute() => action(); } } diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Tests/ReactiveUI.Primitives.WinUI.Tests.csproj b/src/tests/ReactiveUI.Primitives.WinUI.Tests/ReactiveUI.Primitives.WinUI.Tests.csproj index e2d7d097..1519b741 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Tests/ReactiveUI.Primitives.WinUI.Tests.csproj +++ b/src/tests/ReactiveUI.Primitives.WinUI.Tests/ReactiveUI.Primitives.WinUI.Tests.csproj @@ -1,10 +1,6 @@ - + $(NetTargetFrameworks) $(WinUITargetFrameworks) @@ -12,7 +8,6 @@ Exe - @@ -22,7 +17,11 @@ true None - true + true + false + false + false + false false win-x64 $(DefineConstants);WINDOWS diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs index 81be19e1..052c1a6f 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs @@ -3,84 +3,132 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Concurrency; +using System.Reactive.Disposables; using System.Windows.Threading; using ReactiveUI.Primitives.Reactive.Concurrency; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.Wpf.Reactive.Tests; -/// Tests scheduler execution on a dedicated WPF STA thread. +/// Tests dispatcher batching and cancellation with manually invoked callbacks. public sealed class DispatcherSequencerTests { - /// Verifies the constructor rejects a null dispatcher. - /// A representing the asynchronous operation. + /// The second value in a scheduled batch. + private const int SecondValue = 2; + + /// Constructor validation rejects a missing dispatcher. + /// The test operation. [Test] - public async Task ConstructorRejectsNullDispatcher() => + public async Task ConstructorRejectsNullDispatcher() + { await Assert.That(static () => new DispatcherSequencer(null!)).ThrowsExactly(); + await Assert.That(static () => new DispatcherSequencer(null!, DispatcherPriority.Normal)) + .ThrowsExactly(); + } - /// Verifies immediate work is posted to and executed on the dispatcher thread. - /// A representing the asynchronous operation. + /// Construction retains dispatcher identity and priority. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnDispatcherThread() + public async Task ConstructorRetainsDispatcherAndPriority() { - using var harness = new DispatcherHarness(); - var scheduler = new DispatcherSequencer(harness.Dispatcher); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _ = scheduler.Schedule(() => completion.TrySetResult(Environment.CurrentManagedThreadId)); - - var ranOnThreadId = await completion.Task; - await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); + var dispatcher = Dispatcher.CurrentDispatcher; + DispatcherSequencer sequencer = new(dispatcher, DispatcherPriority.Background); + await Assert.That(sequencer.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(sequencer.Priority).IsEqualTo(DispatcherPriority.Background); + await Assert.That(new DispatcherSequencer(dispatcher).Priority).IsEqualTo(DispatcherPriority.Normal); } - /// Verifies due work executes on the dispatcher thread. - /// A representing the asynchronous operation. + /// A posted batch preserves order and skips cancelled work. + /// The test operation. [Test] - public async Task DueScheduleExecutesOnDispatcherThread() + public async Task ScheduleCoalescesOrderedWorkAndSkipsCancellation() { - using var harness = new DispatcherHarness(); - var scheduler = new DispatcherSequencer(harness.Dispatcher); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _ = scheduler.Schedule(TimeSpan.Zero, () => completion.TrySetResult(Environment.CurrentManagedThreadId)); - - var ranOnThreadId = await completion.Task; - await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); + Queue drains = new(); + DispatcherSequencer scheduler = new( + Dispatcher.CurrentDispatcher, + DispatcherPriority.Normal, + drain => + { + drains.Enqueue(drain); + return true; + }, + null); + List values = []; + using var first = scheduler.Schedule(() => values.Add(1)); + var cancelled = scheduler.Schedule(() => values.Add(0)); + using var second = scheduler.Schedule(TimeSpan.Zero, () => values.Add(SecondValue)); + cancelled.Dispose(); + await Assert.That(values).IsEmpty(); + await Assert.That(drains).Count().IsEqualTo(1); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); } - /// Owns a WPF dispatcher and its STA message loop. - private sealed class DispatcherHarness : IDisposable + /// Scheduling inside a callback posts another batch. + /// The test operation. + [Test] + public async Task ScheduleDuringDrainWaitsForTheNextDrain() { - /// The thread running the dispatcher message loop. - private readonly Thread _thread; - - /// Initializes a new instance of the class and waits until the dispatcher is running. - public DispatcherHarness() - { - using var ready = new ManualResetEventSlim(false); - _thread = new(() => + Queue drains = new(); + DispatcherSequencer scheduler = new( + Dispatcher.CurrentDispatcher, + DispatcherPriority.Normal, + drain => { - Dispatcher = Dispatcher.CurrentDispatcher; - ThreadId = Environment.CurrentManagedThreadId; - ready.Set(); - Dispatcher.Run(); - }) { IsBackground = true, Name = "WpfDispatcherHarness" }; + drains.Enqueue(drain); + return true; + }, + null); + List values = []; + using var first = scheduler.Schedule(() => + { + values.Add(1); + _ = scheduler.Schedule(() => values.Add(SecondValue)); + }); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1], EqualityComparer.Default, CollectionOrdering.Matching); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); + } - _thread.SetApartmentState(ApartmentState.STA); - _thread.Start(); - ready.Wait(); + /// Disposal cancels a delayed timer and suppresses even a late callback. + /// Whether to cancel before delivery. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel) + { + Queue<(Action Callback, TimeSpan Delay, BooleanDisposable Cancellation)> delayed = new(); + DispatcherSequencer scheduler = new( + Dispatcher.CurrentDispatcher, + DispatcherPriority.Normal, + static _ => throw new InvalidOperationException("Unexpected immediate dispatch."), + (callback, delay) => + { + BooleanDisposable cancellation = new(); + delayed.Enqueue((callback, delay, cancellation)); + return cancellation; + }); + var calls = 0; + var delay = TimeSpan.FromSeconds(1); + var handle = scheduler.Schedule(delay, () => calls++); + var pending = delayed.Dequeue(); + await Assert.That(pending.Delay).IsEqualTo(delay); + await Assert.That(calls).IsEqualTo(0); + if (cancel) + { + handle.Dispose(); } - /// Gets the hosted dispatcher. - public Dispatcher Dispatcher { get; private set; } = null!; - - /// Gets the managed thread id the dispatcher runs on. - public int ThreadId { get; private set; } - - /// - public void Dispose() + pending.Callback(); + await Assert.That(pending.Cancellation.IsDisposed).IsEqualTo(cancel); + await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); + if (cancel) { - Dispatcher.InvokeShutdown(); - _thread.Join(); + return; } + + handle.Dispose(); } } diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs index 666a6753..ee1bedec 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs @@ -4,110 +4,138 @@ using System.Runtime.CompilerServices; using System.Windows.Threading; +using ReactiveUI.Primitives.Advanced; using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using TUnit.Assertions.Enums; namespace ReactiveUI.Primitives.Wpf.Tests; -/// Tests dispatcher execution on a dedicated WPF STA thread. +/// Tests dispatcher batching and cancellation with manually invoked callbacks. public sealed class DispatcherSequencerTests { - /// Verifies the constructor rejects a null dispatcher. - /// A representing the asynchronous operation. + /// The second value in a scheduled batch. + private const int SecondValue = 2; + + /// Constructor validation rejects a missing dispatcher. + /// The test operation. [Test] - public async Task ConstructorRejectsNullDispatcher() => + public async Task ConstructorRejectsNullDispatcher() + { await Assert.That(static () => new DispatcherSequencer(null!)).ThrowsExactly(); + await Assert.That(static () => new DispatcherSequencer(null!, DispatcherPriority.Normal)) + .ThrowsExactly(); + } - /// Verifies the clock uses UTC and debugger text identifies the sequencer. - /// A task representing the asynchronous test operation. + /// Construction retains dispatcher identity, priority, and UTC clock semantics. + /// The test operation. [Test] - public async Task ClockUsesUtcAndDebuggerTextIdentifiesSequencer() + public async Task ConstructorRetainsDispatcherAndPriority() { - using var harness = new DispatcherHarness(); - DispatcherSequencer sequencer = new(harness.Dispatcher); + var dispatcher = Dispatcher.CurrentDispatcher; + DispatcherSequencer sequencer = new(dispatcher, DispatcherPriority.Background); + await Assert.That(sequencer.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(sequencer.Priority).IsEqualTo(DispatcherPriority.Background); + await Assert.That(new DispatcherSequencer(dispatcher).Priority).IsEqualTo(DispatcherPriority.Normal); await Assert.That(sequencer.Now.Offset).IsEqualTo(TimeSpan.Zero); await Assert.That(sequencer.DebuggerDisplay).IsEqualTo(typeof(DispatcherSequencer).FullName); } - /// Verifies immediate work is posted to and executed on the dispatcher thread. - /// A representing the asynchronous operation. + /// A posted batch preserves order and skips cancelled work. + /// The test operation. [Test] - public async Task ImmediateScheduleExecutesOnDispatcherThread() + public async Task ScheduleCoalescesOrderedWorkAndSkipsCancellation() { - using var harness = new DispatcherHarness(); - var sequencer = new DispatcherSequencer(harness.Dispatcher); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId))); - - var ranOnThreadId = await completion.Task; - await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); + Queue drains = new(); + DispatcherSequencer sequencer = new( + Dispatcher.CurrentDispatcher, + DispatcherPriority.Normal, + drain => + { + drains.Enqueue(drain); + return true; + }, + null); + List values = []; + RecordingWorkItem cancelled = new(() => values.Add(0)); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(1))); + sequencer.Schedule(cancelled); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(SecondValue)), 0); + cancelled.Dispose(); + await Assert.That(values).IsEmpty(); + await Assert.That(drains).Count().IsEqualTo(1); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); } - /// Verifies due work executes on the dispatcher thread. - /// A representing the asynchronous operation. + /// Reentrant scheduling is delivered by a later batch. + /// The test operation. [Test] - public async Task DueScheduleExecutesOnDispatcherThread() + public async Task ScheduleDuringDrainWaitsForTheNextDrain() { - using var harness = new DispatcherHarness(); - var sequencer = new DispatcherSequencer(harness.Dispatcher); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var due = sequencer.Timestamp; - sequencer.Schedule(new DelegateWorkItem(() => completion.TrySetResult(Environment.CurrentManagedThreadId)), due); - - var ranOnThreadId = await completion.Task; - await Assert.That(ranOnThreadId).IsEqualTo(harness.ThreadId); + Queue drains = new(); + DispatcherSequencer sequencer = new( + Dispatcher.CurrentDispatcher, + DispatcherPriority.Normal, + drain => + { + drains.Enqueue(drain); + return true; + }, + null); + List values = []; + sequencer.Schedule(new RecordingWorkItem(() => + { + values.Add(1); + sequencer.Schedule(new RecordingWorkItem(() => values.Add(SecondValue))); + })); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1], EqualityComparer.Default, CollectionOrdering.Matching); + drains.Dequeue()(); + await Assert.That(values).IsEquivalentTo([1, SecondValue], EqualityComparer.Default, CollectionOrdering.Matching); } - /// Work item that invokes a delegate when executed. - private sealed class DelegateWorkItem : IWorkItem + /// Delayed callbacks preserve cancellation before their explicit delivery. + /// Whether to cancel before delivery. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DelayedScheduleWaitsForItsCallback(bool cancel) { - /// The action to run on execution. - private readonly Action _action; - - /// Initializes a new instance of the class. - /// The action to run on execution. - public DelegateWorkItem(Action action) => _action = action; + Queue<(IWorkItem Item, long Due)> delayed = new(); + DispatcherSequencer sequencer = new( + Dispatcher.CurrentDispatcher, + DispatcherPriority.Normal, + static _ => throw new InvalidOperationException("Unexpected immediate dispatch."), + (item, due) => delayed.Enqueue((item, due))); + var calls = 0; + RecordingWorkItem item = new(() => calls++); + sequencer.Schedule(item, long.MaxValue); + await Assert.That(calls).IsEqualTo(0); + var pending = delayed.Dequeue(); + await Assert.That(pending.Due).IsEqualTo(long.MaxValue); + if (cancel) + { + item.Dispose(); + } - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Execute() => _action(); + DispatchSequencerState.RunIfActive(pending.Item); + await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); } - /// Owns a WPF dispatcher and its STA message loop. - private sealed class DispatcherHarness : IDisposable + /// Records execution and supports cancellation. + /// The callback to run. + private sealed class RecordingWorkItem(Action action) : IWorkItem, IsDisposed { - /// The thread running the dispatcher message loop. - private readonly Thread _thread; - - /// Initializes a new instance of the class and waits until the dispatcher is running. - public DispatcherHarness() - { - using var ready = new ManualResetEventSlim(false); - _thread = new(() => - { - Dispatcher = Dispatcher.CurrentDispatcher; - ThreadId = Environment.CurrentManagedThreadId; - ready.Set(); - Dispatcher.Run(); - }) { IsBackground = true, Name = "WpfDispatcherHarness" }; - - _thread.SetApartmentState(ApartmentState.STA); - _thread.Start(); - ready.Wait(); - } - - /// Gets the hosted dispatcher. - public Dispatcher Dispatcher { get; private set; } = null!; + /// + public bool IsDisposed { get; private set; } - /// Gets the managed thread id the dispatcher runs on. - public int ThreadId { get; private set; } + /// + public void Dispose() => IsDisposed = true; /// - public void Dispose() - { - Dispatcher.InvokeShutdown(); - _thread.Join(); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Execute() => action(); } } From b0340a1a00fca5e79e8f71e0f53d664e66c089b1 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:05:59 +1000 Subject: [PATCH 07/11] further --- CLAUDE.md | 9 +++--- .../Concurrency/Sequencer.cs | 30 +++++++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c4987c45..264dd8e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,12 +134,11 @@ dotnet test "tests/ReactiveUI.Primitives.Async.Tests/ReactiveUI.Primitives.Async --treenode-filter "/*/*/*/Async" ``` -### API Approval Notes +### Public API Checks -- API approval baselines live under `src/tests/**/ApiApprovalTests.*.verified.txt` -- New TFMs usually require corresponding new `DotNet11_0.verified.txt` files -- If approval tests fail with `.received.txt` output, inspect the generated snapshot and promote it intentionally if the - API change is expected +- `PublicApiSharp.Analyzers` checks each package's `src//PublicAPI//PublicAPI.txt` baseline. +- New target frameworks require a corresponding baseline directory. +- For an intentional API change, review the affected signatures and update the corresponding framework baselines. --- diff --git a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs index 5393717f..1fa9b3b1 100644 --- a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs @@ -72,10 +72,18 @@ internal static TimeSpan ToTimeSpanDelta(long timestampDelta) return TimeSpan.Zero; } - var ticks = timestampDelta * (double)TimeSpan.TicksPerSecond / System.Diagnostics.Stopwatch.Frequency; - return ticks >= TimeSpan.MaxValue.Ticks - ? TimeSpan.MaxValue - : TimeSpan.FromTicks(Math.Max(1, (long)Math.Ceiling(ticks))); + var frequency = System.Diagnostics.Stopwatch.Frequency; + var seconds = timestampDelta / frequency; + if (seconds >= TimeSpan.MaxValue.Ticks / TimeSpan.TicksPerSecond) + { + return TimeSpan.MaxValue; + } + + // Integer arithmetic: scaling through double rounds a representable duration up to the next tick. + var remainder = timestampDelta % frequency; + var ticks = (seconds * TimeSpan.TicksPerSecond) + + ((remainder * TimeSpan.TicksPerSecond) + frequency - 1) / frequency; + return TimeSpan.FromTicks(Math.Max(1, ticks)); } /// Converts a relative duration to monotonic timestamp ticks. @@ -89,7 +97,17 @@ internal static long ToTimestampDelta(TimeSpan dueTime) return 0; } - var ticks = normalized.TotalSeconds * System.Diagnostics.Stopwatch.Frequency; - return ticks >= long.MaxValue ? long.MaxValue : Math.Max(1, (long)Math.Ceiling(ticks)); + var frequency = System.Diagnostics.Stopwatch.Frequency; + var seconds = normalized.Ticks / TimeSpan.TicksPerSecond; + if (seconds >= long.MaxValue / frequency) + { + return long.MaxValue; + } + + // Integer arithmetic: scaling through double rounds a representable duration up to the next unit. + var remainder = normalized.Ticks % TimeSpan.TicksPerSecond; + var delta = (seconds * frequency) + + ((remainder * frequency) + TimeSpan.TicksPerSecond - 1) / TimeSpan.TicksPerSecond; + return Math.Max(1, delta); } } From 7fad6ef7439245fd219c084c14b92944b87d7ad1 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:15:44 +1000 Subject: [PATCH 08/11] fix(concurrency): convert durations and timestamps with integer math - Scale between ticks and monotonic timestamps without a double round trip, so a duration representable on both grids survives unchanged. - A 50-tick delay converted at a 10 MHz timestamp frequency no longer lands a tick late, which left scheduled work undue at its own due time. --- src/ReactiveUI.Primitives/Concurrency/Sequencer.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs index 1fa9b3b1..051ea788 100644 --- a/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/Sequencer.cs @@ -80,9 +80,8 @@ internal static TimeSpan ToTimeSpanDelta(long timestampDelta) } // Integer arithmetic: scaling through double rounds a representable duration up to the next tick. - var remainder = timestampDelta % frequency; var ticks = (seconds * TimeSpan.TicksPerSecond) - + ((remainder * TimeSpan.TicksPerSecond) + frequency - 1) / frequency; + + ((((timestampDelta % frequency) * TimeSpan.TicksPerSecond) + frequency - 1) / frequency); return TimeSpan.FromTicks(Math.Max(1, ticks)); } @@ -105,9 +104,9 @@ internal static long ToTimestampDelta(TimeSpan dueTime) } // Integer arithmetic: scaling through double rounds a representable duration up to the next unit. - var remainder = normalized.Ticks % TimeSpan.TicksPerSecond; var delta = (seconds * frequency) - + ((remainder * frequency) + TimeSpan.TicksPerSecond - 1) / TimeSpan.TicksPerSecond; + + ((((normalized.Ticks % TimeSpan.TicksPerSecond) * frequency) + TimeSpan.TicksPerSecond - 1) + / TimeSpan.TicksPerSecond); return Math.Max(1, delta); } } From 99f1b5dc1f34817cca0d743b9f870427004f00fd Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:28:00 +1000 Subject: [PATCH 09/11] docs(extensions): name what each parameter carries - Replace placeholder parameter text with the role the argument plays. - Say which window a sequencer times, and which side an observer receives. --- .../Operators/BufferUntilIdleObservable.cs | 2 +- .../Operators/DebounceUntilObservable.cs | 2 +- .../Operators/DetectStaleObservable.cs | 2 +- .../Operators/ScheduleConfig.cs | 2 +- .../Operators/SyncTimerObservable.cs | 4 +- .../ReactiveExtensions.cs | 42 +++++++++---------- .../Operators/PartitionObservable.cs | 6 +-- 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs index e0f494f1..7372182c 100644 --- a/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/BufferUntilIdleObservable.cs @@ -35,7 +35,7 @@ public IDisposable Subscribe(IObserver> observer) /// Sink that accumulates elements and emits the buffer once the idle timer elapses. /// The downstream observer. /// The idle time period. - /// The scheduler. + /// The sequencer that times the idle period. private sealed class BufferUntilIdleSink( IObserver> downstream, TimeSpan idleTime, diff --git a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs index 47772429..6c7f5a37 100644 --- a/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DebounceUntilObservable.cs @@ -39,7 +39,7 @@ public IDisposable Subscribe(IObserver observer) /// The downstream observer. /// The debounce duration. /// The condition. - /// The scheduler. + /// The sequencer that times the debounce window. private sealed class DebounceUntilSink( IObserver downstream, TimeSpan debounce, diff --git a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs index 5aadf57f..8854d5c2 100644 --- a/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/DetectStaleObservable.cs @@ -37,7 +37,7 @@ public IDisposable Subscribe(IObserver> observer) /// Sink that re-arms the staleness timer on each upstream value and emits a stale marker when the window elapses. /// The downstream observer. /// The staleness period. - /// The scheduler. + /// The sequencer that times the staleness window. private sealed class DetectStaleSink( IObserver> downstream, TimeSpan stalenessPeriod, diff --git a/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs b/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs index fa93ee26..a69cd481 100644 --- a/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs +++ b/src/Primitives.Extensions.Shared/Operators/ScheduleConfig.cs @@ -55,7 +55,7 @@ internal static ScheduleConfig Absolute(ISequencer scheduler, DateTimeOffset internal ScheduleConfig WithTransform(Func transform) => this with { Transform = transform }; /// Returns a new config with the supplied side-effect invoked with each value before emission. - /// The action. + /// The work to run. /// A new configuration. internal ScheduleConfig WithAction(Action action) => this with { Action = action }; } diff --git a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs index 41a531a6..15615085 100644 --- a/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs +++ b/src/Primitives.Extensions.Shared/Operators/SyncTimerObservable.cs @@ -25,7 +25,7 @@ internal static class SyncTimerObservable /// Gets a shared timer for the specified period and scheduler. /// The period. - /// The scheduler. + /// The sequencer that times each tick. /// A shared observable sequence of timer ticks. internal static IObservable Get(TimeSpan timeSpan, ISequencer scheduler) { @@ -36,7 +36,7 @@ internal static IObservable Get(TimeSpan timeSpan, ISequencer schedule /// Broadcasts ticks through immutable observer snapshots; subscription changes publish a new snapshot under the gate. /// The period. - /// The scheduler. + /// The sequencer that times each tick. private sealed class SharedTimer(TimeSpan timeSpan, ISequencer scheduler) : IObservable { /// Sentinel empty observer array, shared so unsubscribing the last observer doesn't allocate. diff --git a/src/Primitives.Extensions.Shared/ReactiveExtensions.cs b/src/Primitives.Extensions.Shared/ReactiveExtensions.cs index b33e1289..6898616c 100644 --- a/src/Primitives.Extensions.Shared/ReactiveExtensions.cs +++ b/src/Primitives.Extensions.Shared/ReactiveExtensions.cs @@ -166,7 +166,7 @@ public IObservable> BufferUntilIdle(TimeSpan idleTime) => /// Emit a batch when the stream goes quiet. /// The idle time. - /// The scheduler. + /// The sequencer that times the quiet period. /// A sequence of buffered lists. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable> BufferUntilIdle(TimeSpan idleTime, ISequencer? scheduler) => @@ -183,7 +183,7 @@ public IObservable CatchIgnore(Action errorAction) /// Detects when a stream becomes inactive for some period of time. /// If source stream does not OnNext any update during this period, it is declared stale. - /// The scheduler. + /// The sequencer that times the inactivity window. /// Observable stale markers or updates. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable> DetectStale(TimeSpan stalenessPeriod, ISequencer scheduler) => @@ -230,7 +230,7 @@ public IObservable ObserveOnSafe(ISequencer? scheduler) => /// Conditionally switch schedulers. /// if set to true [condition]. - /// The scheduler. + /// The sequencer used while the condition holds. /// An IObservable of T. public IObservable ObserveOnIf(bool condition, ISequencer scheduler) => condition ? new ObserveOnObservable(source, scheduler) : source; @@ -285,7 +285,7 @@ public IObservable ScanWithInitial( /// Schedules the specified due time. /// The due time. - /// The scheduler. + /// The sequencer the work runs on. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable Schedule(TimeSpan dueTime, ISequencer scheduler) => @@ -293,7 +293,7 @@ public IObservable Schedule(TimeSpan dueTime, ISequencer scheduler) => /// Schedules the specified due time. /// The due time. - /// The scheduler. + /// The sequencer the work runs on. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable Schedule(DateTimeOffset dueTime, ISequencer scheduler) => @@ -301,8 +301,8 @@ public IObservable Schedule(DateTimeOffset dueTime, ISequencer scheduler) => /// Schedules the specified due time. /// The due time. - /// The scheduler. - /// The action. + /// The sequencer the work runs on. + /// The work to run. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable Schedule(TimeSpan dueTime, ISequencer scheduler, Action action) => @@ -310,15 +310,15 @@ public IObservable Schedule(TimeSpan dueTime, ISequencer scheduler, Action /// Schedules the specified due time. /// The due time. - /// The scheduler. - /// The action. + /// The sequencer the work runs on. + /// The work to run. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable Schedule(DateTimeOffset dueTime, ISequencer scheduler, Action action) => new ScheduledSourceObservable(source, ScheduleConfig.Absolute(scheduler, dueTime).WithAction(action)); /// Schedules the specified due time. - /// The scheduler. + /// The sequencer the work runs on. /// The function. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -327,7 +327,7 @@ public IObservable Schedule(ISequencer scheduler, Func function) => /// Schedules the specified due time. /// The due time. - /// The scheduler. + /// The sequencer the work runs on. /// The function. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -538,7 +538,7 @@ public IObservable ThrottleDistinct(TimeSpan throttle) => /// Throttle but only emit when the value actually changes. /// The throttle. - /// The scheduler. + /// The sequencer that times the throttle window. /// A throttled distinct sequence. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable ThrottleDistinct(TimeSpan throttle, ISequencer scheduler) => @@ -789,7 +789,7 @@ public IObservable SkipWhileNull() /// Operators for an observable source sequence that may emit null values. /// The type of the source. - /// The source. + /// The source observable. extension(IObservable source) { /// Catch exception and return Observable.Empty. @@ -993,7 +993,7 @@ public IObservable Using(Func function, ISequencer /// Change-notification operators for a notifying object. /// The type of the source. - /// The source. + /// The notifying object. extension(T source) where T : INotifyPropertyChanged { @@ -1032,7 +1032,7 @@ public IObservable Schedule(TimeSpan dueTime, ISequencer scheduler) => /// Schedules the specified due time. /// The due time. - /// The scheduler. + /// The sequencer the work runs on. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable Schedule(DateTimeOffset dueTime, ISequencer scheduler) => @@ -1040,8 +1040,8 @@ public IObservable Schedule(DateTimeOffset dueTime, ISequencer scheduler) => /// Schedules the specified due time. /// The due time. - /// The scheduler. - /// The action. + /// The sequencer the work runs on. + /// The work to run. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable Schedule(TimeSpan dueTime, ISequencer scheduler, Action action) => @@ -1049,15 +1049,15 @@ public IObservable Schedule(TimeSpan dueTime, ISequencer scheduler, Action /// Schedules the specified due time. /// The due time. - /// The scheduler. - /// The action. + /// The sequencer the work runs on. + /// The work to run. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable Schedule(DateTimeOffset dueTime, ISequencer scheduler, Action action) => new ScheduledValueObservable(value, scheduler, null, dueTime, null, action); /// Schedules the specified due time. - /// The scheduler. + /// The sequencer the work runs on. /// The function. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -1066,7 +1066,7 @@ public IObservable Schedule(ISequencer scheduler, Func function) => /// Schedules the specified due time. /// The due time. - /// The scheduler. + /// The sequencer the work runs on. /// The function. /// An IObservable of T. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs index d6a352d6..68317e9f 100644 --- a/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs +++ b/src/ReactiveUI.Primitives.Extensions.Core/Operators/PartitionObservable.cs @@ -55,7 +55,7 @@ public PartitionObservable(IObservable source, Func predicate) public IObservable False { get; } /// Subscribes an observer to the specified side of the partition. - /// The observer. + /// The observer receiving that side's elements. /// The side (true or false). /// A disposable to unsubscribe. private Subscription Subscribe(IObserver observer, bool side) @@ -77,14 +77,14 @@ private Subscription Subscribe(IObserver observer, bool side) /// Represents a subscription to the partition. /// The parent observable. - /// The observer. + /// The observer receiving that side's elements. /// The side. private sealed class Subscription(PartitionObservable parent, IObserver observer, bool side) : IDisposable { /// The parent observable. private readonly PartitionObservable _parent = parent; - /// The observer. + /// The observer receiving this side's elements. private readonly IObserver _observer = observer; /// The side. From afd5a199871d81b3d9a5ce15d8c17a85d88e58f3 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:24:09 +1000 Subject: [PATCH 10/11] Add further tests --- src/Primitives.Shared/Advanced/EverySignal.cs | 15 +- .../Advanced/FromAsyncSubscription{T}.cs | 172 ++++++++------- .../Advanced/FromAsyncTaskObservation{T}.cs | 17 +- .../SignalOperatorParityMixins.FlatMap.cs | 196 +++++++++--------- .../Disposables/OnceDisposable.cs | 12 +- .../PublicAPI/net10.0/PublicAPI.txt | 1 + .../PublicAPI/net11.0/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0/PublicAPI.txt | 1 + .../PublicAPI/net9.0/PublicAPI.txt | 1 + .../Advanced/TaskResultCompletionSource.cs | 21 +- .../Operators/ToAsyncEnumerable.cs | 16 +- .../Advanced/BufferWitness.cs | 7 +- .../Advanced/UseSignal{TResource,T}.cs | 9 +- .../Signals/Signal{T}.cs | 13 +- .../Concurrency/CurrentThreadSequencer.cs | 4 +- .../BooleanDisposableTests.cs | 33 +++ .../OnceDisposableTests.cs | 27 +++ .../ScopeTests.cs | 31 +++ .../AsyncContextTests.cs | 24 +++ .../ConcurrentSignalAsyncTests.cs | 29 +++ .../IntervalSubscriptionTests.cs | 26 +++ .../LeadSubscriptionTests.cs | 32 +++ .../ReattemptSubscriptionTests.cs | 39 ++++ .../RefCountSignalTests.cs | 31 +++ .../SerialBroadcastHelpersTests.cs | 37 ++++ .../SignalAsyncExtensionsTests.cs | 111 ++++++++++ .../SignalAsyncTests.cs | 33 +++ .../TaskResultCompletionSourceTests.cs | 102 +++++++++ .../YieldSignalTests.cs | 46 ++++ .../AvaloniaSchedulerTests.cs | 15 ++ .../BlazorRendererSequencerTests.cs | 39 ++++ .../ReactiveComponentBaseTests.cs | 18 ++ .../WhileObservableTests.cs | 68 ++++++ ...FirstMatchFromCandidatesObservableTests.cs | 39 ++++ ...activeExtensionsTests.DefaultSchedulers.cs | 67 ++++++ .../ReactiveExtensionsTests.RetryDefaults.cs | 99 +++++++++ .../EverySignalTests.cs | 73 +++++++ .../AwaitWitnessTests.cs | 24 +++ .../BehaviorSignalTests.cs | 33 +++ .../BufferSignalTests.cs | 30 +++ .../BufferWitnessTests.cs | 34 +++ .../CollectArrayWitnessTests.cs | 31 +++ .../CollectListWitnessTests.cs | 31 +++ .../CurrentThreadSequencerTests.cs | 58 ++++++ .../DeferSignalTests.cs | 35 ++++ .../DelayableNotificationSignalTests.cs | 26 +++ .../DisposedMarkerTests.cs | 24 +++ .../DistinctByWitnessTests.cs | 46 ++++ .../EmitIfQuietSignalTests.cs | 28 +++ .../EmptySignalTests.cs | 27 +++ .../FromAsyncSubscriptionTests.cs | 103 +++++++++ .../FromAsyncTaskObservationTests.cs | 42 ++++ .../FromEnumerableSignalTests.cs | 19 ++ .../IgnoreValuesWitnessTests.cs | 31 +++ .../KeepNotNullWitnessTests.cs | 42 ++++ .../KeepTypeWitnessTests.cs | 42 ++++ ...inqExtensionsTests.SubscriptionDisposal.cs | 146 +++++++++++++ .../LinqExtensionsTests.cs | 21 ++ .../LoopSignalTests.cs | 22 ++ .../ObserverHandlerTests.cs | 33 +++ .../ReadOnlyStateTests.cs | 62 ++++++ .../RxVoidTests.cs | 27 +++ .../SequencerQueueTests.cs | 45 ++++ .../SequencerTests.Durations.cs | 38 ++++ .../SequencerTests.WorkItems.cs | 1 + .../SignalExtensionsTests.cs | 45 ++++ .../SignalFactoriesTests.DefaultSequencer.cs | 23 ++ .../StateSignalTests.cs | 33 +++ .../SynchronizedSequencerQueueTests.cs | 30 +++ .../ThrowSignalTests.cs | 29 +++ .../TimeIntervalWitnessTests.cs | 32 +++ .../UnsparkWitnessTests.cs | 30 +++ .../VirtualTimeSequencerExtensionsTests.cs | 30 +++ .../ControlSequencerTests.cs | 12 ++ .../ControlSequencerTests.cs | 25 +++ .../DispatcherQueueSequencerTests.cs | 23 ++ .../DispatcherQueueSequencerTests.cs | 28 +++ .../DispatcherSequencerTests.cs | 14 ++ 82 files changed, 2703 insertions(+), 261 deletions(-) create mode 100644 src/tests/ReactiveUI.Disposables.Tests/BooleanDisposableTests.cs create mode 100644 src/tests/ReactiveUI.Disposables.Tests/OnceDisposableTests.cs create mode 100644 src/tests/ReactiveUI.Disposables.Tests/ScopeTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/AsyncContextTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalAsyncTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/LeadSubscriptionTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/ReattemptSubscriptionTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/RefCountSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/SerialBroadcastHelpersTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/TaskResultCompletionSourceTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Async.Tests/YieldSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/WhileObservableTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesObservableTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.DefaultSchedulers.cs create mode 100644 src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.RetryDefaults.cs create mode 100644 src/tests/ReactiveUI.Primitives.Reactive.Tests/EverySignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/AwaitWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/BufferSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/BufferWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/CollectArrayWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/CollectListWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/CurrentThreadSequencerTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/DeferSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/DisposedMarkerTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/DistinctByWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/EmitIfQuietSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/EmptySignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/IgnoreValuesWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/KeepNotNullWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/KeepTypeWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.SubscriptionDisposal.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/ObserverHandlerTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/RxVoidTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Durations.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/SignalExtensionsTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/SynchronizedSequencerQueueTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/ThrowSignalTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/TimeIntervalWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/UnsparkWitnessTests.cs create mode 100644 src/tests/ReactiveUI.Primitives.Tests/VirtualTimeSequencerExtensionsTests.cs diff --git a/src/Primitives.Shared/Advanced/EverySignal.cs b/src/Primitives.Shared/Advanced/EverySignal.cs index a958d980..fac0681c 100644 --- a/src/Primitives.Shared/Advanced/EverySignal.cs +++ b/src/Primitives.Shared/Advanced/EverySignal.cs @@ -31,20 +31,7 @@ public IDisposable Subscribe(IObserver observer) ArgumentExceptionHelper.ThrowIfNull(observer); EveryCoordinator coordinator = new(observer, _scheduler, _period); - if (!IsRequiredSubscribeOnCurrentThread() || !CurrentThreadSequencer.IsScheduleRequired) - { - return coordinator.Run(); - } - - SingleDisposable subscription = new(); - _ = Sequencer.CurrentThread.Schedule( - (subscription, coordinator), - static (_, s) => - { - s.subscription.Create(s.coordinator.Run()); - return EmptyDisposable.Instance; - }); - return subscription; + return coordinator.Run(); } /// Emits each tick and re-arms the schedule for the following one. diff --git a/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs b/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs index 5ac15f6f..4b1aad92 100644 --- a/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs +++ b/src/Primitives.Shared/Advanced/FromAsyncSubscription{T}.cs @@ -59,6 +59,88 @@ public void Dispose() ExternalCancellation.Dispose(); } + /// Forwards a successful task result. + /// The task result. + /// The downstream observer. + /// The subscription lifetime. + /// The external cancellation forwarder. + /// The linked token source, when an external token was supplied. + /// because the task was completed synchronously. + internal static bool CompleteSynchronously( + T value, + IObserver observer, + AsyncSubscriptionLifetime lifetime, + FromAsyncExternalCancellation externalCancellation, + CancellationTokenSource? linkedSource) + { + linkedSource?.Dispose(); + externalCancellation.Dispose(); + if (!lifetime.TryComplete()) + { + return true; + } + + observer.OnNext(value); + observer.OnCompleted(); + return true; + } + + /// Forwards a canceled task result. + /// The canceled task to observe. + /// The downstream observer. + /// The subscription lifetime. + /// The external cancellation forwarder. + /// The linked token source, when an external token was supplied. + /// because the task was completed synchronously. + internal static bool CancelSynchronously( + Task task, + IObserver observer, + AsyncSubscriptionLifetime lifetime, + FromAsyncExternalCancellation externalCancellation, + CancellationTokenSource? linkedSource) + { + linkedSource?.Dispose(); + if (externalCancellation.TryForwardCancellation()) + { + externalCancellation.Dispose(); + return true; + } + + externalCancellation.Dispose(); + if (!lifetime.TryComplete()) + { + return true; + } + + observer.OnError(new TaskCanceledException(task)); + return true; + } + + /// Forwards a faulted task result. + /// The faulted task to observe. + /// The downstream observer. + /// The subscription lifetime. + /// The external cancellation forwarder. + /// The linked token source, when an external token was supplied. + /// because the task was completed synchronously. + internal static bool FaultSynchronously( + Task task, + IObserver observer, + AsyncSubscriptionLifetime lifetime, + FromAsyncExternalCancellation externalCancellation, + CancellationTokenSource? linkedSource) + { + linkedSource?.Dispose(); + externalCancellation.Dispose(); + if (!lifetime.TryComplete()) + { + return true; + } + + observer.OnError(task.Exception!.InnerException!); + return true; + } + /// Starts the task factory and returns the active subscription lifetime. /// The active subscription, or an empty disposable when the task completed synchronously. internal IDisposable Start() @@ -151,94 +233,4 @@ private static bool TryCompleteSynchronously( ? CancelSynchronously(task, observer, lifetime, externalCancellation, linkedSource) : task.IsFaulted && FaultSynchronously(task, observer, lifetime, externalCancellation, linkedSource); } - - /// Forwards a successful task result. - /// The task result. - /// The downstream observer. - /// The subscription lifetime. - /// The external cancellation forwarder. - /// The linked token source, when an external token was supplied. - /// because the task was completed synchronously. - private static bool CompleteSynchronously( - T value, - IObserver observer, - AsyncSubscriptionLifetime lifetime, - FromAsyncExternalCancellation externalCancellation, - CancellationTokenSource? linkedSource) - { - linkedSource?.Dispose(); - externalCancellation.Dispose(); - if (!lifetime.TryComplete()) - { - return true; - } - - observer.OnNext(value); - observer.OnCompleted(); - return true; - } - - /// Forwards a canceled task result. - /// The task to observe. - /// The downstream observer. - /// The subscription lifetime. - /// The external cancellation forwarder. - /// The linked token source, when an external token was supplied. - /// because the task was completed synchronously. - private static bool CancelSynchronously( - Task task, - IObserver observer, - AsyncSubscriptionLifetime lifetime, - FromAsyncExternalCancellation externalCancellation, - CancellationTokenSource? linkedSource) - { - linkedSource?.Dispose(); - if (externalCancellation.TryForwardCancellation()) - { - externalCancellation.Dispose(); - return true; - } - - externalCancellation.Dispose(); - if (!lifetime.TryComplete()) - { - return true; - } - - observer.OnError(new TaskCanceledException(task)); - return true; - } - - /// Forwards a faulted task result. - /// The task to observe. - /// The downstream observer. - /// The subscription lifetime. - /// The external cancellation forwarder. - /// The linked token source, when an external token was supplied. - /// because the task was completed synchronously. - private static bool FaultSynchronously( - Task task, - IObserver observer, - AsyncSubscriptionLifetime lifetime, - FromAsyncExternalCancellation externalCancellation, - CancellationTokenSource? linkedSource) - { - linkedSource?.Dispose(); - externalCancellation.Dispose(); - if (!lifetime.TryComplete()) - { - return true; - } - - if (task.Exception is { InnerException: { } innerException }) - { - observer.OnError(innerException); - } - else if (task.Exception is { } exception) - { - observer.OnError(exception); - } - - return true; - } } diff --git a/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs b/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs index b58d328e..c27e9a4a 100644 --- a/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs +++ b/src/Primitives.Shared/Advanced/FromAsyncTaskObservation{T}.cs @@ -48,17 +48,13 @@ public FromAsyncTaskObservation( private CancellationTokenSource? LinkedSource { get; } /// Observes the completed task and forwards its terminal result unless the subscription was disposed. - /// The task to observe. + /// The completed task to observe. internal void Observe(Task task) { try { ObserveCore(task); } - catch (Exception) when (Lifetime.IsCancellationRequested) - { - // Subscription cancellation does not send a terminal notification. - } catch (Exception error) { OnError(error); @@ -73,7 +69,7 @@ internal void Observe(Task task) } /// Forwards the task terminal state. - /// The task to observe. + /// The completed task to observe. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Concurrency", "PSH1315:A blocking wait on an awaitable that may not be done", @@ -92,14 +88,7 @@ private void ObserveCore(Task task) return; } - if (task.Exception is { InnerException: { } innerException }) - { - OnError(innerException); - } - else if (task.Exception is { } exception) - { - OnError(exception); - } + OnError(task.Exception!.InnerException!); } /// Forwards a successful task result. diff --git a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs index 5e1aa6b1..896ff974 100644 --- a/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs +++ b/src/Primitives.Shared/SignalOperatorParityMixins.FlatMap.cs @@ -14,71 +14,10 @@ namespace ReactiveUI.Primitives; /// FlatMap helper implementations. public static partial class LinqExtensions { - /// FlatMap signal that subscribes the projected inner observables in source order. - /// The source value type. - /// The result value type. - /// The source observable. - /// Projects source values to inner observables. - private sealed class FlatMapSignal(IObservable source, Func> selector) : IObservable - { - /// The source observable. - private readonly IObservable _source = source; - - /// Projects source values to inner observables. - private readonly Func> _selector = selector; - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - return new FlatMapCoordinator(_source, _selector, observer).Run(); - } - } - - /// Chaining FlatMap signal with an outer/inner result selector. - /// The source value type. - /// The inner value type. - /// The result value type. - /// The source observable. - /// Projects source values to inner observables. - /// Projects outer and inner values to result values. - private sealed class FlatMapResultSignal( - IObservable source, - Func> collectionSelector, - Func resultSelector) : IObservable - { - /// The source observable. - private readonly IObservable _source = source; - - /// Projects source values to inner observables. - private readonly Func> _collectionSelector = collectionSelector; - - /// Projects outer and inner values to result values. - private readonly Func _resultSelector = resultSelector; - - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - - var collectionSelector = _collectionSelector; - var resultSelector = _resultSelector; - - return new FlatMapCoordinator( - _source, - value => new FlatMapResultInnerSignal( - value, - collectionSelector(value), - resultSelector), - observer).Run(); - } - } - /// Coordinates chain-style FlatMap subscriptions. /// The source value type. /// The result value type. - private sealed class FlatMapCoordinator : IDisposable + internal sealed class FlatMapCoordinator : IDisposable { /// Synchronizes subscription state. private readonly Lock _gate = new(); @@ -184,6 +123,42 @@ internal IDisposable Run() } } + /// Subscribes to an inner source. + /// The inner source. + internal void SubscribeInner(IObservable inner) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _subscribingInner = true; + _completedInnerWhileSubscribing = false; + } + + IDisposable subscription; + try + { + subscription = inner.Subscribe(_innerObserver); + } + catch (Exception error) + { + CompleteSubscribe(error); + return; + } + + var completed = CompleteSubscribe(subscription); + if (!completed) + { + return; + } + + subscription.Dispose(); + Drain(); + } + /// Handles a source value. /// The source value. /// The selector returned a inner observable. @@ -312,42 +287,6 @@ private bool TryStartOrQueue(IObservable inner) } } - /// Subscribes to an inner source. - /// The inner source. - private void SubscribeInner(IObservable inner) - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _subscribingInner = true; - _completedInnerWhileSubscribing = false; - } - - IDisposable subscription; - try - { - subscription = inner.Subscribe(_innerObserver); - } - catch (Exception error) - { - CompleteSubscribe(error); - return; - } - - var completed = CompleteSubscribe(subscription); - if (!completed) - { - return; - } - - subscription.Dispose(); - Drain(); - } - /// Completes an inner subscribe call that threw. /// The subscribe error. private void CompleteSubscribe(Exception error) @@ -467,6 +406,67 @@ private sealed class InnerWitness : IObserver } } + /// FlatMap signal that subscribes the projected inner observables in source order. + /// The source value type. + /// The result value type. + /// The source observable. + /// Projects source values to inner observables. + private sealed class FlatMapSignal(IObservable source, Func> selector) : IObservable + { + /// The source observable. + private readonly IObservable _source = source; + + /// Projects source values to inner observables. + private readonly Func> _selector = selector; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + return new FlatMapCoordinator(_source, _selector, observer).Run(); + } + } + + /// Chaining FlatMap signal with an outer/inner result selector. + /// The source value type. + /// The inner value type. + /// The result value type. + /// The source observable. + /// Projects source values to inner observables. + /// Projects outer and inner values to result values. + private sealed class FlatMapResultSignal( + IObservable source, + Func> collectionSelector, + Func resultSelector) : IObservable + { + /// The source observable. + private readonly IObservable _source = source; + + /// Projects source values to inner observables. + private readonly Func> _collectionSelector = collectionSelector; + + /// Projects outer and inner values to result values. + private readonly Func _resultSelector = resultSelector; + + /// + public IDisposable Subscribe(IObserver observer) + { + ArgumentExceptionHelper.ThrowIfNull(observer); + + var collectionSelector = _collectionSelector; + var resultSelector = _resultSelector; + + return new FlatMapCoordinator( + _source, + value => new FlatMapResultInnerSignal( + value, + collectionSelector(value), + resultSelector), + observer).Run(); + } + } + /// Maps inner values with a captured outer value. /// The source value type. /// The inner value type. diff --git a/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs b/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs index 6e572e77..cb00cc17 100644 --- a/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs +++ b/src/ReactiveUI.Disposables/Disposables/OnceDisposable.cs @@ -54,16 +54,8 @@ public IDisposable? Disposable private string DebuggerDisplay => ToString() ?? string.Empty; /// - public void Dispose() - { - var previous = Interlocked.Exchange(ref _current, DisposedSentinel); - if (previous is null || ReferenceEquals(previous, DisposedSentinel)) - { - return; - } - - previous.Dispose(); - } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public void Dispose() => Interlocked.Exchange(ref _current, DisposedSentinel)?.Dispose(); /// Disposable marker for disposed instances. private sealed class DisposedMarker : IDisposable diff --git a/src/ReactiveUI.Disposables/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net10.0/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net10.0/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Disposables/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net11.0/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net11.0/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Disposables/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net462/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net462/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Disposables/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net472/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net472/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Disposables/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net48/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net48/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Disposables/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net481/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net481/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Disposables/PublicAPI/net8.0/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net8.0/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net8.0/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net8.0/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Disposables/PublicAPI/net9.0/PublicAPI.txt b/src/ReactiveUI.Disposables/PublicAPI/net9.0/PublicAPI.txt index 27a3172d..cc903483 100644 --- a/src/ReactiveUI.Disposables/PublicAPI/net9.0/PublicAPI.txt +++ b/src/ReactiveUI.Disposables/PublicAPI/net9.0/PublicAPI.txt @@ -98,6 +98,7 @@ public sealed class OnceDisposable : ReactiveUI.Primitives.Disposables.IsDispose public System.IDisposable? Disposable { get; set; } public bool IsAssigned { get; } public bool IsDisposed { get; } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public void Dispose() { } } [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] diff --git a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs index 6a6f89d9..2f7a731e 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Advanced/TaskResultCompletionSource.cs @@ -23,19 +23,26 @@ public sealed class TaskResultCompletionSource(CancellationToken cancellation /// The terminal result value. public async ValueTask AwaitResultAsync(IAsyncDisposable owner) { + CancellationTokenRegistration cancellationRegistration = default; try { -#if NET8_0_OR_GREATER - await using var cancellationRegistration = RegisterCancellation(); -#else - using var cancellationRegistration = RegisterCancellation(); -#endif - + cancellationRegistration = RegisterCancellation(); return await _taskSource.Task.ConfigureAwait(false); } finally { - await owner.DisposeAsync().ConfigureAwait(false); + try + { +#if NET8_0_OR_GREATER + await cancellationRegistration.DisposeAsync().ConfigureAwait(false); +#else + cancellationRegistration.Dispose(); +#endif + } + finally + { + await owner.DisposeAsync().ConfigureAwait(false); + } } } diff --git a/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs b/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs index f901a5d8..aa6983c9 100644 --- a/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs +++ b/src/ReactiveUI.Primitives.Async.Core/Operators/ToAsyncEnumerable.cs @@ -60,7 +60,7 @@ static async IAsyncEnumerable ReadObservableValuesAsync( return default; }); - await using var subscription = await source.SubscribeAsync( + var subscription = await source.SubscribeAsync( channel.Writer.WriteAsync, onErrorResumeAsync, result => @@ -70,9 +70,19 @@ static async IAsyncEnumerable ReadObservableValuesAsync( }, cancellationToken).ConfigureAwait(false); - await foreach (var x in channel.Reader.ReadAllAsync(cancellationToken)) + try { - yield return x; + await foreach (var x in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return x; + } + } + finally + { + if (subscription is not null) + { + await subscription.DisposeAsync().ConfigureAwait(false); + } } } } diff --git a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs index 2b05786c..2df299dd 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/BufferWitness.cs @@ -121,17 +121,12 @@ public void Dispose() SinkSubscription.Dispose(ref _subscription); } - /// Returns the window array, copying to an exact-size array only for a partial trailing window. + /// Copies the partial trailing window into an exact-size array. /// The window buffer. /// The number of filled elements. /// The window array. private static T[] Trim(T[] buffer, int length) { - if (length == buffer.Length) - { - return buffer; - } - var exact = new T[length]; Array.Copy(buffer, exact, length); return exact; diff --git a/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs b/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs index 8eba8a96..3aebb749 100644 --- a/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs +++ b/src/ReactiveUI.Primitives.Core/Advanced/UseSignal{TResource,T}.cs @@ -75,18 +75,13 @@ private sealed class UseWitness(IObserver observer, TResource resource) : IOb /// Non-zero once stopped. private int _stopped; - /// Stores the inner subscription, disposing it when the sink has stopped or holds one. + /// Publishes the single inner subscription, disposing it if the sink has already stopped. /// Inner subscription. public void SetSubscription(IDisposable subscription) { ArgumentExceptionHelper.ThrowIfNull(subscription); - if (Interlocked.CompareExchange(ref _subscription, subscription, null) is not null) - { - subscription.Dispose(); - return; - } - + Volatile.Write(ref _subscription, subscription); if (Volatile.Read(ref _stopped) == 0) { return; diff --git a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs index 1bfcf994..c7948474 100644 --- a/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs +++ b/src/ReactiveUI.Primitives.Core/Signals/Signal{T}.cs @@ -485,16 +485,9 @@ private void Remove(SignalSubscription subscription) /// Removes a subscription from its owning slot array. /// The active slot array. /// The subscription to clear. - private void RemoveFromSlotsLocked(SignalSubscription?[] slots, SignalSubscription subscription) - { - var index = Array.IndexOf(slots, subscription); - if (index < 0) - { - return; - } - - ClearSlotLocked(slots, index); - } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private void RemoveFromSlotsLocked(SignalSubscription?[] slots, SignalSubscription subscription) => + ClearSlotLocked(slots, Array.IndexOf(slots, subscription)); /// Vacates one slot and drops back to the empty shape when it was the last live subscription. /// The active slot array. diff --git a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs index 8e17ef32..652b2cd8 100644 --- a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs @@ -110,10 +110,10 @@ public void Schedule(IWorkItem item, long dueTimestamp) { SetRunning(true); - WaitIfNeeded(Sequencer.TimeUntil(dueTimestamp), Wait); - try { + WaitIfNeeded(Sequencer.TimeUntil(dueTimestamp), Wait); + if (!Sequencer.IsCancelled(item)) { item.Execute(); diff --git a/src/tests/ReactiveUI.Disposables.Tests/BooleanDisposableTests.cs b/src/tests/ReactiveUI.Disposables.Tests/BooleanDisposableTests.cs new file mode 100644 index 00000000..05af936c --- /dev/null +++ b/src/tests/ReactiveUI.Disposables.Tests/BooleanDisposableTests.cs @@ -0,0 +1,33 @@ +// 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.Runtime.CompilerServices; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Disposables.Tests; + +/// Tests the boolean disposal latch. +public class BooleanDisposableTests +{ + /// The debugger can display both live and disposed instances without altering their state. + /// A task representing the asynchronous test. + [Test] + public async Task DebuggerDisplay_PreservesDisposalState() + { + BooleanDisposable disposable = new(); + await Assert.That(GetDebuggerDisplay(disposable)).IsEqualTo(disposable.ToString()); + await Assert.That(disposable.IsDisposed).IsFalse(); + + disposable.Dispose(); + + await Assert.That(GetDebuggerDisplay(disposable)).IsEqualTo(disposable.ToString()); + await Assert.That(disposable.IsDisposed).IsTrue(); + } + + /// Invokes the getter used by the debugger without reflection. + /// The instance to display. + /// The debugger display text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string GetDebuggerDisplay(BooleanDisposable disposable); +} diff --git a/src/tests/ReactiveUI.Disposables.Tests/OnceDisposableTests.cs b/src/tests/ReactiveUI.Disposables.Tests/OnceDisposableTests.cs new file mode 100644 index 00000000..1f4bfcdf --- /dev/null +++ b/src/tests/ReactiveUI.Disposables.Tests/OnceDisposableTests.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.Primitives.Disposables; + +namespace ReactiveUI.Disposables.Tests; + +/// Tests repeated disposal of a single-assignment holder. +public class OnceDisposableTests +{ + /// The terminal marker tolerates repeated disposal without releasing the resource again. + /// A task representing the asynchronous test. + [Test] + public async Task Dispose_Repeated_ReleasesAssignedResourceOnce() + { + int[] counts = [0]; + OnceDisposable holder = new() { Disposable = Scope.Create(counts, static state => state[0]++) }; + + holder.Dispose(); + holder.Dispose(); + + await Assert.That(counts[0]).IsEqualTo(1); + await Assert.That(holder.IsDisposed).IsTrue(); + await Assert.That(holder.Disposable).IsNull(); + } +} diff --git a/src/tests/ReactiveUI.Disposables.Tests/ScopeTests.cs b/src/tests/ReactiveUI.Disposables.Tests/ScopeTests.cs new file mode 100644 index 00000000..01861a81 --- /dev/null +++ b/src/tests/ReactiveUI.Disposables.Tests/ScopeTests.cs @@ -0,0 +1,31 @@ +// 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.Primitives.Disposables; + +namespace ReactiveUI.Disposables.Tests; + +/// Tests grouped resource ownership. +public class ScopeTests +{ + /// Both combination overloads release every resource once. + /// Whether to use the array overload instead of the pair overload. + /// A task representing the asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Combine_DisposeTwice_ReleasesBothResourcesOnce(bool useArray) + { + int[] counts = [0, 0]; + using var first = Scope.Create(counts, static state => state[0]++); + using var second = Scope.Create(counts, static state => state[1]++); + var combined = useArray ? Scope.Combine([first, second]) : Scope.Combine(first, second); + + combined.Dispose(); + combined.Dispose(); + + await Assert.That(counts[0]).IsEqualTo(1); + await Assert.That(counts[1]).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncContextTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncContextTests.cs new file mode 100644 index 00000000..69287e9e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncContextTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests continuation dispatch through asynchronous contexts. +public sealed class AsyncContextTests +{ + /// A forced default-context switch dispatches its continuation to the thread pool. + /// The test operation. + [Test] + public async Task SwitchContextAsync_DefaultContext_QueuesContinuation() + { + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + var awaiter = AsyncContext.Default.SwitchContextAsync(true, CancellationToken.None).GetAwaiter(); + + await Assert.That(awaiter.IsCompleted).IsFalse(); + awaiter.OnCompleted(() => completed.SetResult(Thread.CurrentThread.IsThreadPoolThread)); + + await Assert.That(await completed.Task).IsTrue(); + awaiter.GetResult(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalAsyncTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalAsyncTests.cs new file mode 100644 index 00000000..2244258e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ConcurrentSignalAsyncTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests the independent lifetimes of concurrent signals and observer leases. +public sealed class ConcurrentSignalAsyncTests +{ + /// Signal disposal preserves subscriptions until their own leases are disposed. + /// The test operation. + [Test] + public async Task DisposeAsync_ActiveSubscription_DoesNotDisposeObserverLease() + { + const int IgnoredValue = 2; + ConcurrentSignalAsync signal = new(); + List values = []; + await using var subscription = await signal.SubscribeAsync(values.Add); + + await signal.DisposeAsync(); + await signal.OnNextAsync(1, CancellationToken.None); + await subscription.DisposeAsync(); + await signal.OnNextAsync(IgnoredValue, CancellationToken.None); + + await Assert.That(values).IsCollectionEqualTo([1]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs index 0db42ad6..ad66b496 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/IntervalSubscriptionTests.cs @@ -7,6 +7,32 @@ namespace ReactiveUI.Primitives.Async.Tests; /// Tests interval cancellation and notification ordering with a controlled clock. public sealed class IntervalSubscriptionTests { + /// A zero period uses the system-clock path without waiting for wall-clock time. + /// Whether the system provider is supplied explicitly. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ExecuteAsync_ZeroPeriod_StopsAfterObserverCancellation(bool explicitSystemProvider) + { + using CancellationTokenSource cancellation = new(); + List ticks = []; + CallbackWitnessAsync observer = new((tick, _) => + { + ticks.Add(tick); + cancellation.Cancel(); + return default; + }); + await using IntervalSubscription subscription = new( + observer, + TimeSpan.Zero, + explicitSystemProvider ? TimeProvider.System : null); + + await subscription.ExecuteAsync(cancellation.Token); + + await Assert.That(ticks).IsCollectionEqualTo([1L]); + } + /// Cancellation while waiting prevents the pending tick from being delivered. /// The test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/LeadSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/LeadSubscriptionTests.cs new file mode 100644 index 00000000..3d1887af --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/LeadSubscriptionTests.cs @@ -0,0 +1,32 @@ +// 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.Primitives.Async.Disposables; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests ownership of the subscription following leading values. +public sealed class LeadSubscriptionTests +{ + /// Repeated disposal releases the upstream subscription once. + /// The test operation. + [Test] + public async Task DisposeAsync_Twice_DisposesSourceOnce() + { + List disposals = []; + var source = SignalAsync.Create((_, _) => new(DisposableAsync.Create(disposals, static state => + { + state.Add(1); + return default; + }))); + CallbackWitnessAsync observer = new(static (_, _) => default); + await using LeadSubscription subscription = new(source, [], observer, CancellationToken.None); + subscription.Start(); + + await subscription.DisposeAsync(); + await subscription.DisposeAsync(); + + await Assert.That(disposals).Count().IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/ReattemptSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/ReattemptSubscriptionTests.cs new file mode 100644 index 00000000..ad7d3489 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/ReattemptSubscriptionTests.cs @@ -0,0 +1,39 @@ +// 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.Primitives.Async.Tests; + +/// Tests forwarding of recoverable errors through retry subscriptions. +public sealed class ReattemptSubscriptionTests +{ + /// A recoverable error preserves the source connection and subsequent values. + /// The test operation. + [Test] + public async Task RelayErrorAsync_RecoverableError_ForwardsErrorAndContinues() + { + DirectSource source = new(); + List values = []; + List errors = []; + Exception expected = new InvalidOperationException("recoverable"); + CallbackWitnessAsync observer = new( + (value, _) => + { + values.Add(value); + return default; + }, + (error, _) => + { + errors.Add(error); + return default; + }); + await using ReattemptSubscription subscription = new(source, observer, 1, CancellationToken.None); + await subscription.SubscribeOnceAsync(); + + await source.EmitError(expected); + await source.EmitNext(1); + + await Assert.That(errors).IsCollectionEqualTo([expected]); + await Assert.That(values).IsCollectionEqualTo([1]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/RefCountSignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/RefCountSignalTests.cs new file mode 100644 index 00000000..18b10ae4 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/RefCountSignalTests.cs @@ -0,0 +1,31 @@ +// 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.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests managed connection ownership during reference-counted disposal. +public sealed class RefCountSignalTests +{ + /// Finalizer-style disposal leaves managed subscriptions available to their owners. + /// The test operation. + [Test] + public async Task Dispose_UnmanagedOnly_PreservesManagedConnection() + { + const int IgnoredValue = 2; + var source = Signal.Create(); + var connected = source.Values.Publish(); + using SignalAsyncExtensions.RefCountSignal signal = new(connected); + List values = []; + await using var subscription = await ((IObservableAsync)signal).SubscribeAsync(values.Add); + + signal.Dispose(false); + await source.OnNextAsync(1, CancellationToken.None); + await subscription.DisposeAsync(); + await source.OnNextAsync(IgnoredValue, CancellationToken.None); + + await Assert.That(values).IsCollectionEqualTo([1]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SerialBroadcastHelpersTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SerialBroadcastHelpersTests.cs new file mode 100644 index 00000000..b526ea89 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SerialBroadcastHelpersTests.cs @@ -0,0 +1,37 @@ +// 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.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests ordering across asynchronous serial observer notifications. +public sealed class SerialBroadcastHelpersTests +{ + /// A pending first observer prevents the second observer from receiving the value early. + /// The test operation. + [Test] + public async Task BroadcastOnNextAsync_PendingFirstObserver_WaitsBeforeNotifyingSecond() + { + var source = Signal.Create(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + List firstValues = []; + List secondValues = []; + await using var first = await source.Values.SubscribeAsync(async (value, _) => + { + firstValues.Add(value); + await release.Task; + }); + await using var second = await source.Values.SubscribeAsync(secondValues.Add); + + var pending = source.OnNextAsync(1, CancellationToken.None).AsTask(); + await Assert.That(firstValues).IsCollectionEqualTo([1]); + await Assert.That(secondValues).IsEmpty(); + await Assert.That(pending.IsCompleted).IsFalse(); + release.SetResult(); + await pending; + + await Assert.That(secondValues).IsCollectionEqualTo([1]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs new file mode 100644 index 00000000..43facb28 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncExtensionsTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Threading.Channels; +using ReactiveUI.Primitives.Async.Disposables; +using ReactiveUI.Primitives.Async.Signals; + +namespace ReactiveUI.Primitives.Async.Tests; + +/// Tests asynchronous signal extension overloads and enumeration disposal. +public sealed class SignalAsyncExtensionsTests +{ + /// The second distinct value in an ordered sequence. + private const int SecondValue = 2; + + /// Chaining an enumerable preserves source order. + /// The test operation. + [Test] + public async Task Chain_Enumerable_EmitsSourcesInOrder() + { + IEnumerable> sources = [SignalAsync.Return(1), SignalAsync.Return(SecondValue)]; + + await Assert.That(await sources.Chain().ToListAsync()).IsCollectionEqualTo([1, SecondValue]); + } + + /// Zero-duration throttling on the system clock still removes consecutive duplicates. + /// The test operation. + [Test] + public async Task ThrottleDistinct_ZeroDuration_EmitsDistinctValues() + { + var source = Signal.Create(); + List values = []; + await using var subscription = await source.Values.ThrottleDistinct(TimeSpan.Zero) + .SubscribeAsync(values.Add); + + await source.OnNextAsync(1, CancellationToken.None); + await source.OnNextAsync(1, CancellationToken.None); + await source.OnNextAsync(SecondValue, CancellationToken.None); + await source.OnCompletedAsync(Result.Success); + + await Assert.That(values).IsCollectionEqualTo([1, SecondValue]); + } + + /// Timeout without an explicit clock selects the system-clock signal. + /// The test operation. + [Test] + public async Task Timeout_DefaultClock_CreatesTimeoutSignal() + { + var source = SignalAsync.Never().Timeout(TimeSpan.FromSeconds(1)); + + await Assert.That(source).IsTypeOf>(); + } + + /// Timeout with a fallback selects the system-clock fallback signal. + /// The test operation. + [Test] + public async Task Timeout_DefaultClockAndFallback_CreatesFallbackSignal() + { + var source = SignalAsync.Never().Timeout(TimeSpan.FromSeconds(1), SignalAsync.Return(1)); + + await Assert.That(source).IsTypeOf>(); + } + + /// Stopping enumeration awaits disposal of the upstream subscription. + /// The test operation. + [Test] + public async Task ToAsyncEnumerable_DisposeWhileSourceIsActive_AwaitsUpstreamDisposal() + { + TaskCompletionSource disposing = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + var source = SignalAsync.Create(async (observer, token) => + { + await observer.OnNextAsync(1, token); + return DisposableAsync.Create((disposing, release), static async state => + { + state.disposing.SetResult(); + await state.release.Task; + }); + }); + var enumerator = source.ToAsyncEnumerable(Channel.CreateUnbounded).GetAsyncEnumerator(); + await Assert.That(await enumerator.MoveNextAsync()).IsTrue(); + await Assert.That(enumerator.Current).IsEqualTo(1); + + var pending = enumerator.DisposeAsync().AsTask(); + await disposing.Task; + await Assert.That(pending.IsCompleted).IsFalse(); + release.SetResult(); + await pending; + } + + /// Stopping enumeration propagates an upstream disposal failure. + /// The test operation. + [Test] + public async Task ToAsyncEnumerable_UpstreamDisposalFails_PropagatesFailure() + { + InvalidOperationException expected = new("upstream disposal failed"); + var source = SignalAsync.Create(async (observer, token) => + { + await observer.OnNextAsync(1, token); + return DisposableAsync.Create(expected, ValueTask.FromException); + }); + var enumerator = source.ToAsyncEnumerable(Channel.CreateUnbounded).GetAsyncEnumerator(); + await Assert.That(await enumerator.MoveNextAsync()).IsTrue(); + + var error = await Assert.That(async () => await enumerator.DisposeAsync()) + .ThrowsExactly(); + + await Assert.That(error).IsSameReferenceAs(expected); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncTests.cs new file mode 100644 index 00000000..eb114164 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SignalAsyncTests.cs @@ -0,0 +1,33 @@ +// 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.Primitives.Async.Tests; + +/// Tests asynchronous signal factories using their default execution settings. +public sealed class SignalAsyncTests +{ + /// The default background-job overload forwards the job's value and completion. + /// The test operation. + [Test] + public async Task CreateAsBackgroundJob_DefaultSettings_ForwardsJobResult() + { + var source = SignalAsync.CreateAsBackgroundJob(static async (observer, token) => + { + await observer.OnNextAsync(1, token); + await observer.OnCompletedAsync(Result.Success); + }); + + await Assert.That(await source.ToListAsync()).IsCollectionEqualTo([1]); + } + + /// The default interval factory creates a system-clock interval. + /// The test operation. + [Test] + public async Task Interval_DefaultSettings_CreatesIntervalSignal() + { + var source = SignalAsync.Interval(TimeSpan.FromSeconds(1)); + + await Assert.That(source).IsTypeOf(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/TaskResultCompletionSourceTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/TaskResultCompletionSourceTests.cs new file mode 100644 index 00000000..b2634f3c --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/TaskResultCompletionSourceTests.cs @@ -0,0 +1,102 @@ +// 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.Primitives.Async.Tests; + +/// Tests ownership cleanup before terminal task results are returned. +public sealed class TaskResultCompletionSourceTests +{ + /// A successful result is returned only after asynchronous owner disposal completes. + /// The test operation. + [Test] + public async Task AwaitResultAsync_Success_AwaitsOwnerDisposal() + { + using CancellationTokenSource cancellation = new(); + TaskResultCompletionSource source = new(cancellation.Token); + ControlledOwner owner = new(); + var pending = source.AwaitResultAsync(owner).AsTask(); + + await source.SetResultAndDisposeAsync(1, owner); + await owner.Disposing.Task; + await Assert.That(pending.IsCompleted).IsFalse(); + owner.Release.SetResult(); + + await Assert.That(await pending).IsEqualTo(1); + await Assert.That(owner.ReentrantDisposalCount).IsEqualTo(1); + } + + /// Cancellation also waits for asynchronous owner disposal before reporting cancellation. + /// The test operation. + [Test] + public async Task AwaitResultAsync_Canceled_AwaitsOwnerDisposal() + { + using CancellationTokenSource cancellation = new(); + TaskResultCompletionSource source = new(cancellation.Token); + ControlledOwner owner = new(); + var pending = source.AwaitResultAsync(owner).AsTask(); + + await cancellation.CancelAsync(); + await owner.Disposing.Task; + await Assert.That(pending.IsCompleted).IsFalse(); + owner.Release.SetResult(); + + var error = await Assert.That(async () => await pending).Throws(); + await Assert.That(error!.CancellationToken).IsEqualTo(cancellation.Token); + } + + /// An owner disposal failure takes precedence over either a result or the original task failure. + /// Whether the task result itself fails. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task AwaitResultAsync_OwnerDisposalFails_PropagatesDisposalFailure(bool sourceFails) + { + TaskResultCompletionSource source = new(CancellationToken.None); + ControlledOwner owner = new(); + InvalidOperationException expected = new("owner disposal failed"); + var pending = source.AwaitResultAsync(owner).AsTask(); + if (sourceFails) + { + await source.SetExceptionAndDisposeAsync(new ArgumentException("source failed"), owner); + } + else + { + await source.SetResultAndDisposeAsync(1, owner); + } + + await owner.Disposing.Task; + owner.Release.SetException(expected); + + var error = await Assert.That(async () => await pending).ThrowsExactly(); + await Assert.That(error).IsSameReferenceAs(expected); + } + + /// Separates notification disposal from externally awaited cleanup. + private sealed class ControlledOwner : IAsyncDisposable, IReentrantAsyncDisposable + { + /// Gets the signal that external disposal has started. + internal TaskCompletionSource Disposing { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the gate controlling completion of external disposal. + internal TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the count of notification-side disposals. + internal int ReentrantDisposalCount { get; private set; } + + /// + public async ValueTask DisposeAsync() + { + Disposing.SetResult(); + await Release.Task; + } + + /// + public ValueTask DisposeFromNotificationAsync() + { + ReentrantDisposalCount++; + return default; + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/YieldSignalTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/YieldSignalTests.cs new file mode 100644 index 00000000..a24ef080 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/YieldSignalTests.cs @@ -0,0 +1,46 @@ +// 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.Primitives.Async.Tests; + +/// Tests yielding to the scheduler captured during subscription. +public sealed class YieldSignalTests +{ + /// Each value waits for the captured scheduler before reaching its observer. + /// The test operation. + [Test] + public async Task SubscribeAsync_CustomScheduler_DefersValuesUntilScheduled() + { + ManualTaskScheduler scheduler = new(); + DirectSource source = new(); + List values = []; + CallbackWitnessAsync observer = new((value, _) => + { + values.Add(value); + return default; + }); + var subscribing = Task.Factory.StartNew( + static state => + { + var (source, observer) = ((DirectSource, CallbackWitnessAsync))state!; + return ((IObservableAsync)source).Yield().SubscribeAsync(observer, CancellationToken.None); + }, + (source, observer), + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + scheduler); + scheduler.RunNext(); + var scheduledSubscription = await subscribing; + await using var subscription = await scheduledSubscription; + + var pending = source.EmitNext(1); + await Assert.That(values).IsEmpty(); + await Assert.That(scheduler.PendingCount).IsEqualTo(1); + + scheduler.RunNext(); + await pending; + + await Assert.That(values).IsCollectionEqualTo([1]); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs index 91bc96be..e48030bc 100644 --- a/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Avalonia.Tests/AvaloniaSchedulerTests.cs @@ -43,6 +43,21 @@ public async Task ConstructorsAndSingletonPreserveDispatcherAndPriority() await Assert.That(AvaloniaScheduler.Instance.Priority).IsEqualTo(DispatcherPriority.Background); } + /// The scheduler exposes UTC time and the shared monotonic timestamp scale. + /// A task representing the asynchronous test. + [Test] + public async Task ClockProperties_UseSharedSequencerClock() + { + AvaloniaScheduler scheduler = new(Dispatcher.UIThread); + var before = System.Diagnostics.Stopwatch.GetTimestamp(); + var timestamp = scheduler.Timestamp; + var after = System.Diagnostics.Stopwatch.GetTimestamp(); + + await Assert.That(scheduler.Now.Offset).IsEqualTo(TimeSpan.Zero); + await Assert.That(timestamp).IsGreaterThanOrEqualTo(before); + await Assert.That(timestamp).IsLessThanOrEqualTo(after); + } + /// Immediate and already-due items share an ordered batch that skips cancelled work. /// The test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs index 19d9f2fd..22ad2c33 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Tests/BlazorRendererSequencerTests.cs @@ -54,6 +54,39 @@ public async Task DispatcherSequencerExecutesWork() await Assert.That(renderer.InvokeCount).IsEqualTo(1); } + /// Already-due timestamp work is delivered through the renderer with the shared clock scale. + /// A task representing the asynchronous test. + [Test] + public async Task Schedule_AlreadyDueTimestamp_UsesRendererDispatch() + { + FakeRenderer renderer = new(); + BlazorRendererSequencer sequencer = new(renderer); + var before = Sequencer.Timestamp; + var timestamp = sequencer.Timestamp; + var after = Sequencer.Timestamp; + var calls = 0; + + sequencer.Schedule(new DelegateWorkItem(() => calls++), timestamp); + + await Assert.That(calls).IsEqualTo(1); + await Assert.That(renderer.InvokeCount).IsEqualTo(1); + await Assert.That(sequencer.Now.Offset).IsEqualTo(TimeSpan.Zero); + await Assert.That(timestamp).IsGreaterThanOrEqualTo(before); + await Assert.That(timestamp).IsLessThanOrEqualTo(after); + } + + /// The debugger identifies the renderer sequencer without posting renderer work. + /// A task representing the asynchronous test. + [Test] + public async Task DebuggerDisplay_IdentifiesSequencerWithoutDispatch() + { + FakeRenderer renderer = new(); + BlazorRendererSequencer sequencer = new(renderer); + + await Assert.That(GetDebuggerDisplay(sequencer)).IsEqualTo(sequencer.ToString()); + await Assert.That(renderer.InvokeCount).IsEqualTo(0); + } + /// Verifies renderer-task faults reach the unhandled-exception handler instead of vanishing. /// A representing the asynchronous operation. [Test] @@ -239,6 +272,12 @@ public async Task ImmediateBurstExecutesInOrder() await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } + /// Invokes the getter used by the debugger without reflection. + /// The sequencer to display. + /// The debugger display text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string GetDebuggerDisplay(BlazorRendererSequencer sequencer); + /// Work item that invokes a delegate when executed. private sealed class DelegateWorkItem : IWorkItem { diff --git a/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs b/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs index beb81f7f..936d0166 100644 --- a/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs +++ b/src/tests/ReactiveUI.Primitives.Blazor.Tests/ReactiveComponentBaseTests.cs @@ -17,6 +17,18 @@ public sealed class ReactiveComponentBaseTests /// The first value pushed to a source. private const int FirstValue = 1; + /// The debugger reflects disposal without attaching the component to a renderer. + /// A task representing the asynchronous test. + [Test] + public async Task DebuggerDisplay_ReportsCurrentDisposalState() + { + HarnessComponent component = new(); + + await Assert.That(GetDebuggerDisplay(component)).IsEqualTo("IsDisposed = False"); + component.Dispose(); + await Assert.That(GetDebuggerDisplay(component)).IsEqualTo("IsDisposed = True"); + } + /// Verifies an observed value reaches the callback and refreshes the component. /// A task representing the asynchronous operation. [Test] @@ -139,6 +151,12 @@ public async Task SecondDisposalIsANoOp() await Assert.That(component.IsDisposedState).IsTrue(); } + /// Invokes the getter used by the debugger without reflection. + /// The component to display. + /// The debugger display text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string GetDebuggerDisplay(ReactiveComponentBase component); + /// A source whose observer the test drives directly. /// The value type. private sealed class ManualSource : IObservable diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/WhileObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/WhileObservableTests.cs new file mode 100644 index 00000000..4f4c5918 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Reactive.Tests/WhileObservableTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using System.Runtime.CompilerServices; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Reactive.Tests; + +/// Tests scheduled loop cancellation after a scheduler has accepted its callback. +public class WhileObservableTests +{ + /// A callback already accepted by the scheduler does not invoke the action after disposal. + /// A task representing the asynchronous test. + [Test] + public async Task While_DisposedBeforeAcceptedCallbackRuns_DoesNotInvokeAction() + { + AcceptedCallbackScheduler scheduler = new(); + var actions = 0; + var values = 0; + var completed = false; + var subscription = ReactiveExtensions.While(static () => true, () => actions++, scheduler) + .Subscribe(_ => values++, () => completed = true); + + subscription.Dispose(); + scheduler.RunAcceptedCallback(); + + await Assert.That(actions).IsEqualTo(0); + await Assert.That(values).IsEqualTo(0); + await Assert.That(completed).IsFalse(); + } + + /// Models best-effort cancellation after dispatch has accepted a callback for execution. + private sealed class AcceptedCallbackScheduler : IScheduler + { + /// The accepted callback, which is no longer cancelable by its subscription. + private Action? _accepted; + + /// + public DateTimeOffset Now => DateTimeOffset.UnixEpoch; + + /// + public IDisposable Schedule(TState state, Func action) + { + _accepted = () => action(this, state).Dispose(); + return EmptyDisposable.Instance; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDisposable Schedule( + TState state, + TimeSpan dueTime, + Func action) => Schedule(state, action); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDisposable Schedule( + TState state, + DateTimeOffset dueTime, + Func action) => Schedule(state, action); + + /// Executes the accepted callback after the test has disposed its subscription. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void RunAcceptedCallback() => _accepted!(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesObservableTests.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesObservableTests.cs new file mode 100644 index 00000000..388460e6 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/Operators/FirstMatchFromCandidatesObservableTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Extensions.Tests.Operators; + +/// Tests candidate matching when a user callback ends the subscription. +public class FirstMatchFromCandidatesObservableTests +{ + /// Disposal from the match predicate prevents delivery of the value being evaluated. + /// A task representing the asynchronous test. + [Test] + public async Task OnNext_PredicateDisposesSubscription_DoesNotDeliverMatch() + { + using Subject source = new(); + MutableDisposable subscription = new(); + List values = []; + var completed = false; + int[] candidates = [0]; + subscription.Disposable = candidates.FirstMatchFromCandidates( + _ => source, + static value => value, + _ => + { + subscription.Dispose(); + return true; + }, + -1).Subscribe(values.Add, () => completed = true); + + source.OnNext(1); + + await Assert.That(values).IsEmpty(); + await Assert.That(completed).IsFalse(); + await Assert.That(source.HasObservers).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.DefaultSchedulers.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.DefaultSchedulers.cs new file mode 100644 index 00000000..0151f816 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.DefaultSchedulers.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests synchronous behavior of operators that use the default scheduler. +public partial class ReactiveExtensionsTests +{ + /// A value satisfying the predicate bypasses the throttle window and completes synchronously. + /// A task representing the asynchronous test. + [Test] + public async Task ThrottleUntilTrue_DefaultSchedulerMatchingValue_ForwardsImmediately() + { + List values = []; + var completed = false; + using var subscription = Observable.Return(SampleValue42) + .ThrottleUntilTrue(TimeSpan.FromDays(1), static value => value == SampleValue42) + .Subscribe(values.Add, () => completed = true); + + await Assert.That(values).IsCollectionEqualTo([SampleValue42]); + await Assert.That(completed).IsTrue(); + } + + /// A source error is forwarded without waiting for the default scheduler's debounce window. + /// A task representing the asynchronous test. + [Test] + public async Task DebounceImmediate_DefaultSchedulerSourceError_ForwardsImmediately() + { + InvalidOperationException failure = new("source"); + Exception? observed = null; + using var subscription = Observable.Throw(failure) + .DebounceImmediate(TimeSpan.FromDays(1)) + .Subscribe(static _ => { }, error => observed = error); + + await Assert.That(observed).IsSameReferenceAs(failure); + } + + /// An empty source completes without producing a buffer or waiting for an inactivity timer. + /// A task representing the asynchronous test. + [Test] + public async Task BufferUntilInactive_DefaultSchedulerEmptySource_CompletesImmediately() + { + List> buffers = []; + var completed = false; + using var subscription = Observable.Empty() + .BufferUntilInactive(TimeSpan.FromDays(1)) + .Subscribe(buffers.Add, () => completed = true); + + await Assert.That(buffers).IsEmpty(); + await Assert.That(completed).IsTrue(); + } + + /// The default overload shares its timer with the explicit default-scheduler overload. + /// A task representing the asynchronous test. + [Test] + public async Task SyncTimer_DefaultScheduler_ReusesExplicitDefaultTimer() + { + var period = TimeSpan.FromDays(1); + var timer = period.SyncTimer(); + + await Assert.That(timer).IsSameReferenceAs(period.SyncTimer(Sequencer.Default)); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.RetryDefaults.cs b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.RetryDefaults.cs new file mode 100644 index 00000000..fc3801bd --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Extensions.Tests/ReactiveExtensionsTests.RetryDefaults.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Linq; + +namespace ReactiveUI.Primitives.Extensions.Tests; + +/// Tests default-scheduler retry overloads with synchronous retries. +public partial class ReactiveExtensionsTests +{ + /// Identifies the original error forwarded by a retry sequence. + private const string RetryFailureMessage = "retry"; + + /// Zero-delay retries notify the error callback only for the requested exception type. + /// Whether the first failure matches the error callback's type. + /// A task representing the asynchronous test. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task OnErrorRetry_DefaultSchedulerZeroDelay_FiltersErrorCallback(bool matchingException) + { + var attempts = 0; + List errors = []; + List values = []; + Exception failure = matchingException + ? new InvalidOperationException(RetryFailureMessage) + : new ArgumentException(RetryFailureMessage); + var source = Observable.Defer(() => + { + attempts++; + return attempts == 1 ? Observable.Throw(failure) : Observable.Return(SampleValue42); + }); + + using var subscription = source.OnErrorRetry(errors.Add, TimeSpan.Zero) + .Subscribe(values.Add); + + await Assert.That(attempts).IsEqualTo(SampleValue2); + await Assert.That(values).IsCollectionEqualTo([SampleValue42]); + await Assert.That(errors.Count).IsEqualTo(matchingException ? 1 : 0); + if (!matchingException) + { + return; + } + + await Assert.That(errors[0]).IsSameReferenceAs(failure); + } + + /// The limited zero-delay overload forwards the final error after spending its retry budget. + /// Whether failures match the error callback's type. + /// A task representing the asynchronous test. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task OnErrorRetry_DefaultSchedulerRetryLimit_ForwardsFinalError(bool matchingException) + { + var attempts = 0; + var callbacks = 0; + Exception? observed = null; + Exception failure = matchingException + ? new InvalidOperationException(RetryFailureMessage) + : new ArgumentException(RetryFailureMessage); + var source = Observable.Defer(() => + { + attempts++; + return Observable.Throw(failure); + }); + + using var subscription = source.OnErrorRetry( + _ => callbacks++, + SampleValue2, + TimeSpan.Zero).Subscribe(static _ => { }, error => observed = error); + + await Assert.That(attempts).IsEqualTo(SampleValue3); + await Assert.That(callbacks).IsEqualTo(matchingException ? SampleValue3 : 0); + await Assert.That(observed).IsSameReferenceAs(failure); + } + + /// Fixed zero-delay retries use the configured budget before forwarding the original error. + /// A task representing the asynchronous test. + [Test] + public async Task RetryWithFixedDelay_ZeroDelay_ForwardsErrorAfterRetryLimit() + { + var attempts = 0; + InvalidOperationException failure = new(RetryFailureMessage); + Exception? observed = null; + var source = Observable.Defer(() => + { + attempts++; + return Observable.Throw(failure); + }); + + using var subscription = source.RetryWithFixedDelay(SampleValue2, TimeSpan.Zero) + .Subscribe(static _ => { }, error => observed = error); + + await Assert.That(attempts).IsEqualTo(SampleValue3); + await Assert.That(observed).IsSameReferenceAs(failure); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Reactive.Tests/EverySignalTests.cs b/src/tests/ReactiveUI.Primitives.Reactive.Tests/EverySignalTests.cs new file mode 100644 index 00000000..82f1e1fb --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Reactive.Tests/EverySignalTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Runtime.CompilerServices; +using ReactiveUI.Primitives.Reactive.Signals; + +namespace ReactiveUI.Primitives.Reactive.Tests; + +/// Tests recurring ticks whose scheduler has already accepted a callback at cancellation. +public class EverySignalTests +{ + /// A callback delivered after disposal emits no tick and cannot schedule a successor. + /// A task representing the asynchronous test. + [Test] + public async Task Every_DisposedBeforeAcceptedCallback_DoesNotEmitOrReschedule() + { + AcceptedCallbackScheduler scheduler = new(); + List values = []; + var subscription = Signal.Every(TimeSpan.Zero, scheduler).Subscribe(values.Add); + + subscription.Dispose(); + scheduler.RunAccepted(); + + await Assert.That(values.Count).IsEqualTo(0); + await Assert.That(scheduler.ScheduleCount).IsEqualTo(1); + await Assert.That(scheduler.HandleDisposed).IsTrue(); + } + + /// Retains an accepted callback independently of the scheduling handle's cancellation state. + private sealed class AcceptedCallbackScheduler : IScheduler + { + /// The callback accepted by the scheduler. + private Action? _pending; + + /// + public DateTimeOffset Now => DateTimeOffset.UnixEpoch; + + /// Gets the number of accepted schedules. + internal int ScheduleCount { get; private set; } + + /// Gets whether cancellation reached the scheduler's handle. + internal bool HandleDisposed { get; private set; } + + /// + public IDisposable Schedule(TState state, Func action) + { + ScheduleCount++; + _pending = () => action(this, state).Dispose(); + return Disposable.Create(this, static scheduler => scheduler.HandleDisposed = true); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) => + Schedule(state, action); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => + Schedule(state, dueTime - Now, action); + + /// Delivers the already accepted callback even if its handle was subsequently cancelled. + internal void RunAccepted() + { + var callback = _pending; + _pending = null; + callback!.Invoke(); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/AwaitWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/AwaitWitnessTests.cs new file mode 100644 index 00000000..ca68a176 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/AwaitWitnessTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests the observer that resumes an awaiting continuation. +public class AwaitWitnessTests +{ + /// An error resumes the continuation inline when context capture is disabled. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_WithoutCapturedContext_InvokesContinuationInline() + { + var calls = 0; + AwaitWitness witness = new(() => calls++, false); + + witness.OnError(new InvalidOperationException("source")); + + await Assert.That(calls).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs index 07fc5708..36509ef5 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs @@ -22,6 +22,17 @@ public class BehaviorSignalTests /// Value that should be ignored after completion. private const int IgnoredAfterCompletionValue = 1234; + /// The debugger display leaves the latest value and observer set intact. + /// A task representing the asynchronous test. + [Test] + public async Task DebuggerDisplay_PreservesSignalState() + { + using BehaviorSignal signal = new(InitialValue); + await Assert.That(GetDebuggerDisplay(signal)).IsEqualTo(signal.ToString()); + await Assert.That(signal.Value).IsEqualTo(InitialValue); + await Assert.That(signal.HasObservers).IsFalse(); + } + /// Verifies a behavior signal rejects a null observer. [MethodImpl(MethodImplOptions.AggressiveInlining)] [Test] @@ -253,4 +264,26 @@ public async Task Subscribe_ReentrantOnNext_FollowsTheInitialValue() }); await Assert.That(values.SequenceEqual([0, 1])).IsTrue(); } + + /// Invokes the getter used by the debugger without reflection. + /// The instance to display. + /// The debugger display text. +#if NET9_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string? GetDebuggerDisplay(BehaviorSignal signal) => DebuggerAccessor.Read(signal); + + /// Matches the target type's generic context required by .NET 9 and later. + /// The signal's value type. + private static class DebuggerAccessor + { + /// Invokes the getter evaluated by the debugger. + /// The signal to display. + /// The debugger text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + internal static extern string? Read(BehaviorSignal signal); + } +#else + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string? GetDebuggerDisplay(BehaviorSignal signal); +#endif } diff --git a/src/tests/ReactiveUI.Primitives.Tests/BufferSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BufferSignalTests.cs new file mode 100644 index 00000000..8449e447 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/BufferSignalTests.cs @@ -0,0 +1,30 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests time-window buffering. +public sealed class BufferSignalTests +{ + /// A zero window emits each source value as a separate buffer without scheduling. + /// The test operation. + [Test] + public async Task Subscribe_WhenWindowIsZero_ThenEmitsIndividualBuffers() + { + const int secondValue = 2; + RecordingWitness> observer = new(); + BufferSignal source = new(Signal.Sequence(1, secondValue), TimeSpan.Zero, Sequencer.Immediate); + + using var subscription = source.Subscribe(observer); + + await Assert.That(observer.Values.Count).IsEqualTo(secondValue); + await Assert.That(observer.Values[0].SequenceEqual([1])).IsTrue(); + await Assert.That(observer.Values[1].SequenceEqual([secondValue])).IsTrue(); + await Assert.That(observer.Completed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/BufferWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BufferWitnessTests.cs new file mode 100644 index 00000000..d2a4e94a --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/BufferWitnessTests.cs @@ -0,0 +1,34 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests buffered observer termination. +public class BufferWitnessTests +{ + /// A duplicate error cannot emit a partial batch or notify the observer twice. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_Repeated_ForwardsOnlyFirstError() + { + const int WindowSize = 2; + RecordingWitness> observer = new(); + RecordingDisposable subscription = new(); + using BufferWitness witness = new(observer, WindowSize, 0); + witness.SetSubscription(subscription); + witness.OnNext(1); + InvalidOperationException error = new("source"); + + witness.OnError(error); + witness.OnError(new InvalidOperationException("late")); + witness.OnCompleted(); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/CollectArrayWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CollectArrayWitnessTests.cs new file mode 100644 index 00000000..c78a2cae --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/CollectArrayWitnessTests.cs @@ -0,0 +1,31 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests array collection notifications. +public class CollectArrayWitnessTests +{ + /// An upstream error is forwarded without emitting a partial array and releases the subscription. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_DiscardsPartialResultAndDisposesSubscription() + { + RecordingWitness observer = new(); + RecordingDisposable subscription = new(); + using CollectArrayWitness witness = new(observer); + witness.SetSubscription(subscription); + witness.OnNext(1); + InvalidOperationException error = new("source"); + + witness.OnError(error); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/CollectListWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CollectListWitnessTests.cs new file mode 100644 index 00000000..b3199be6 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/CollectListWitnessTests.cs @@ -0,0 +1,31 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests list collection notifications. +public class CollectListWitnessTests +{ + /// An upstream error is forwarded without emitting a partial list and releases the subscription. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_DiscardsPartialResultAndDisposesSubscription() + { + RecordingWitness> observer = new(); + RecordingDisposable subscription = new(); + using CollectListWitness witness = new(observer); + witness.SetSubscription(subscription); + witness.OnNext(1); + InvalidOperationException error = new("source"); + + witness.OnError(error); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/CurrentThreadSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CurrentThreadSequencerTests.cs new file mode 100644 index 00000000..831d03a4 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/CurrentThreadSequencerTests.cs @@ -0,0 +1,58 @@ +// 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.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests current-thread scheduling recovery after an interrupted initial wait. +public class CurrentThreadSequencerTests +{ + /// An interrupted initial wait clears the trampoline state so later work on that thread still executes. + /// A task representing the asynchronous test. + [Test] + public async Task Schedule_InterruptedInitialWait_AllowsSubsequentWork() + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + var interrupted = false; + List firstWorkPending = [1]; + var subsequentWorkRan = false; + var resetAfterInterruption = false; + var resetAfterWork = false; + Thread thread = new(() => + { + try + { + var sequencer = Sequencer.CurrentThread; + Thread.CurrentThread.Interrupt(); + try + { + using var pending = sequencer.Schedule(TimeSpan.FromDays(1), firstWorkPending.Clear); + } + catch (ThreadInterruptedException) + { + interrupted = true; + } + + resetAfterInterruption = CurrentThreadSequencer.IsScheduleRequired; + using var next = sequencer.Schedule(() => subsequentWorkRan = true); + resetAfterWork = CurrentThreadSequencer.IsScheduleRequired; + completion.SetResult(); + } + catch (Exception error) + { + completion.SetException(error); + } + }); + + thread.Start(); + await completion.Task; + + await Assert.That(interrupted).IsTrue(); + await Assert.That(firstWorkPending.Count).IsEqualTo(1); + await Assert.That(resetAfterInterruption).IsTrue(); + await Assert.That(subsequentWorkRan).IsTrue(); + await Assert.That(resetAfterWork).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/DeferSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DeferSignalTests.cs new file mode 100644 index 00000000..0fe5228b --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/DeferSignalTests.cs @@ -0,0 +1,35 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests deferred source creation. +public sealed class DeferSignalTests +{ + /// Each subscription creates its source directly without requesting a current-thread trampoline. + /// The test operation. + [Test] + public async Task Subscribe_WhenRepeated_ThenCreatesOneSourcePerObserver() + { + const int secondValue = 2; + var calls = 0; + DeferSignal source = new(() => + { + calls++; + return Signal.Return(calls); + }); + RecordingWitness first = new(); + RecordingWitness second = new(); + + using var firstSubscription = source.Subscribe(first); + using var secondSubscription = source.Subscribe(second); + + await Assert.That(source.IsRequiredSubscribeOnCurrentThread()).IsFalse(); + await Assert.That(first.Values.SequenceEqual([1])).IsTrue(); + await Assert.That(second.Values.SequenceEqual([secondValue])).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/DelayableNotificationSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DelayableNotificationSignalTests.cs index cecc592f..2d693213 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/DelayableNotificationSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/DelayableNotificationSignalTests.cs @@ -103,6 +103,32 @@ public async Task ObserverStateAndDisposal() await Assert.That(signal.IsDisposed).IsTrue(); } + /// Completion drops a pending batch, ignores repeated terminals, and is replayed to late subscribers. + /// A task representing the asynchronous test. + [Test] + public async Task OnCompleted_Repeated_DropsBufferAndCompletesLateSubscribers() + { + using DelayableNotificationSignal signal = new(static () => true, static items => items); + RecordingWitness observer = new(); + using var subscription = signal.Subscribe(observer); + signal.Flush(); + signal.OnNext(1); + signal.OnCompleted(); + + signal.OnCompleted(); + signal.OnError(new InvalidOperationException("late")); + signal.Flush(); + RecordingWitness late = new(); + using var lateSubscription = signal.Subscribe(late); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Completed).IsEqualTo(1); + await Assert.That(observer.Errors.Count).IsEqualTo(0); + await Assert.That(late.Values.Count).IsEqualTo(0); + await Assert.That(late.Completed).IsEqualTo(1); + await Assert.That(late.Errors.Count).IsEqualTo(0); + } + /// The factory helpers build working signal instances. /// A task representing the asynchronous operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Tests/DisposedMarkerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DisposedMarkerTests.cs new file mode 100644 index 00000000..1776a134 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/DisposedMarkerTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests the terminal subscription marker. +public class DisposedMarkerTests +{ + /// Disposing a terminal marker is harmless even when repeated. + /// A task representing the asynchronous test. + [Test] + public async Task Dispose_Repeated_DoesNotThrow() + { + DisposedMarker marker = new(); + await Assert.That(() => + { + marker.Dispose(); + marker.Dispose(); + }).ThrowsNothing(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/DistinctByWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/DistinctByWitnessTests.cs new file mode 100644 index 00000000..b5499b0e --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/DistinctByWitnessTests.cs @@ -0,0 +1,46 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests the distinct-key observer's terminal and failure handling. +public class DistinctByWitnessTests +{ + /// A failed value callback propagates its exception and releases the source. + /// A task representing the asynchronous test. + [Test] + public async Task OnNext_ObserverThrows_DisposesSubscription() + { + RecordingDisposable subscription = new(); + using DistinctByWitness witness = new(new ThrowingWitness(throwOnNext: true), static value => value, null); + witness.SetSubscription(subscription); + + await Assert.That(() => witness.OnNext(1)).ThrowsExactly(); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } + + /// The first source error ends distinct-key delivery and releases its subscription. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_ForwardsOnceAndIgnoresLaterNotifications() + { + RecordingWitness observer = new(); + RecordingDisposable subscription = new(); + using DistinctByWitness witness = new(observer, static value => value, null); + witness.SetSubscription(subscription); + InvalidOperationException error = new("source"); + + witness.OnError(error); + witness.OnNext(1); + witness.OnError(error); + witness.OnCompleted(); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/EmitIfQuietSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/EmitIfQuietSignalTests.cs new file mode 100644 index 00000000..c78ce96c --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/EmitIfQuietSignalTests.cs @@ -0,0 +1,28 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests quiet-period value delivery. +public sealed class EmitIfQuietSignalTests +{ + /// A zero quiet period forwards every value immediately. + /// The test operation. + [Test] + public async Task Subscribe_WhenDueTimeIsZero_ThenForwardsEveryValue() + { + const int secondValue = 2; + RecordingWitness observer = new(); + EmitIfQuietSignal source = new(Signal.Sequence(1, secondValue), TimeSpan.Zero, Sequencer.Immediate); + + using var subscription = source.Subscribe(observer); + + await Assert.That(observer.Values.SequenceEqual([1, secondValue])).IsTrue(); + await Assert.That(observer.Completed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/EmptySignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/EmptySignalTests.cs new file mode 100644 index 00000000..ed373f98 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/EmptySignalTests.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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests empty signal completion. +public sealed class EmptySignalTests +{ + /// The immediate sequencer completes inline without requesting a current-thread trampoline. + /// The test operation. + [Test] + public async Task Subscribe_WhenImmediate_ThenCompletesInline() + { + EmptySignal source = new(Sequencer.Immediate); + RecordingWitness observer = new(); + + using var subscription = source.Subscribe(observer); + + await Assert.That(source.IsRequiredSubscribeOnCurrentThread()).IsFalse(); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs index e0289f27..ff94fc3d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncSubscriptionTests.cs @@ -36,4 +36,107 @@ public async Task ExternalCancellationDuringTheFactoryForwardsCancellationAndDro await Assert.That(witness.Errors.Count).IsEqualTo(1); await Assert.That(witness.Errors[0]).IsTypeOf(); } + + /// A canceled factory task forwards external cancellation even if another callback interrupted notification. + /// The asynchronous test. + [Test] + public async Task Start_InterruptedCancellationCallbacks_ForwardsCancellationOnce() + { + using CancellationTokenSource externalCancellation = new(); + RecordingWitness witness = new(); + InvalidOperationException expected = new("cancellation callback failed"); + Exception? callbackFailure = null; + using FromAsyncSubscription subscription = new( + witness, + _ => + { + using var registration = externalCancellation.Token.UnsafeRegister(static state => throw (Exception)state!, expected); + try + { + externalCancellation.Cancel(true); + } + catch (InvalidOperationException error) + { + callbackFailure = error; + } + + return Task.FromCanceled(externalCancellation.Token); + }, + externalCancellation.Token); + + using var handle = subscription.Start(); + + await Assert.That(callbackFailure).IsSameReferenceAs(expected); + await Assert.That(witness.Values).IsEmpty(); + await Assert.That(witness.Completed).IsEqualTo(0); + await Assert.That(witness.Errors).Count().IsEqualTo(1); + await Assert.That(witness.Errors[0]).IsTypeOf(); + } + + /// A completion helper cannot send a second terminal notification after external cancellation won. + /// The terminal status of the factory task. + /// The asynchronous test. + [Test] + [Arguments(TaskStatus.RanToCompletion)] + [Arguments(TaskStatus.Canceled)] + [Arguments(TaskStatus.Faulted)] + public async Task CompleteSynchronously_ExternalCancellationAlreadyWon_DoesNotNotifyAgain(TaskStatus status) + { + using CancellationTokenSource externalSource = new(); + using AsyncSubscriptionLifetime lifetime = new(); + RecordingWitness observer = new(); + using FromAsyncExternalCancellation cancellation = new(observer, lifetime, externalSource.Token); + using var linkedSource = cancellation.CreateLinkedSource(lifetime.Token); + _ = cancellation.Start(); + await externalSource.CancelAsync(); + + var completed = status switch + { + TaskStatus.RanToCompletion => FromAsyncSubscription.CompleteSynchronously( + FactoryValue, + observer, + lifetime, + cancellation, + linkedSource), + TaskStatus.Canceled => FromAsyncSubscription.CancelSynchronously( + Task.FromCanceled(externalSource.Token), + observer, + lifetime, + cancellation, + linkedSource), + TaskStatus.Faulted => FromAsyncSubscription.FaultSynchronously( + Task.FromException(new InvalidOperationException("task failed")), + observer, + lifetime, + cancellation, + linkedSource), + _ => false + }; + + await Assert.That(completed).IsTrue(); + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Errors).Count().IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsTypeOf(); + } + + /// Synchronous fault forwarding preserves the original exception, including an empty aggregate. + /// Whether the original failure is an aggregate with no inner exceptions. + /// The asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task FaultSynchronously_FaultedTask_PreservesOriginalException(bool aggregate) + { + RecordingWitness observer = new(); + Exception expected = aggregate ? new AggregateException() : new InvalidOperationException("task failed"); + using FromAsyncSubscription subscription = new(observer, _ => Task.FromException(expected)); + + using var handle = subscription.Start(); + + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Errors).Count().IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(expected); + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs index f1d3ab39..026d9524 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FromAsyncTaskObservationTests.cs @@ -44,6 +44,48 @@ public async Task UncancelableTokenRunsFactory() await Assert.That(observer.Error).IsNull(); } + /// A task fault is forwarded once and completes the subscription lifetime. + /// Whether the supplied failure is an aggregate with no inner exceptions. + /// The asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Observe_FaultedTask_ForwardsOriginalFailure(bool aggregate) + { + using AsyncSubscriptionLifetime lifetime = new(); + RecordingWitness observer = new(); + using FromAsyncExternalCancellation cancellation = new(observer, lifetime, CancellationToken.None); + FromAsyncTaskObservation observation = new(observer, lifetime, cancellation, null); + Exception expected = aggregate ? new AggregateException() : new InvalidOperationException("task failed"); + + observation.Observe(Task.FromException(expected)); + + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(observer.Errors).Count().IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(expected); + await Assert.That(lifetime.IsCompleted).IsTrue(); + } + + /// A fault arriving after disposal does not notify the canceled observer. + /// The asynchronous test. + [Test] + public async Task Observe_DisposedLifetime_DropsTaskFailure() + { + AsyncSubscriptionLifetime lifetime = new(); + RecordingWitness observer = new(); + using FromAsyncExternalCancellation cancellation = new(observer, lifetime, CancellationToken.None); + FromAsyncTaskObservation observation = new(observer, lifetime, cancellation, null); + lifetime.Dispose(); + + observation.Observe(Task.FromException(new InvalidOperationException("task failed"))); + + await Assert.That(observer.Values).IsEmpty(); + await Assert.That(observer.Errors).IsEmpty(); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(lifetime.IsCompleted).IsTrue(); + } + /// Throws after recording a value and optionally disposing its lifetime. /// The subscription lifetime. /// Whether to dispose before throwing. diff --git a/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs index 2b0d01fa..ad551735 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FromEnumerableSignalTests.cs @@ -69,6 +69,25 @@ public async Task FromEnumerableSignalCoversAllSynchronousFastPaths() _ = Assert.Throws(() => arraySignal.Subscribe(static _ => { }, static ex => { }, null!)); } + /// A cancelled enumeration delivers neither values nor completion and cannot expose an uncancellable fast path. + /// A task representing the asynchronous test. + [Test] + public async Task Subscribe_CancelledToken_StopsBeforeFirstValue() + { + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + FromEnumerableSignal signal = new(ExpectedOneTwo, cancellation.Token); + RecordingWitness observer = new(); + + using var subscription = signal.Subscribe(observer); + var canReadDirectly = signal.TryGetReadOnlyValues(out var values); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(canReadDirectly).IsFalse(); + await Assert.That(values.Count).IsEqualTo(0); + } + /// Creates an iterator-backed enumerable that cannot be indexed. /// The yielded values. private static IEnumerable YieldValues() diff --git a/src/tests/ReactiveUI.Primitives.Tests/IgnoreValuesWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/IgnoreValuesWitnessTests.cs new file mode 100644 index 00000000..be419801 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/IgnoreValuesWitnessTests.cs @@ -0,0 +1,31 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests forwarding terminal notifications while dropping values. +public class IgnoreValuesWitnessTests +{ + /// Errors still reach the observer when values are ignored, and release the subscription. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_ForwardsErrorAndDisposesSubscription() + { + RecordingWitness observer = new(); + RecordingDisposable subscription = new(); + using IgnoreValuesWitness witness = new(observer); + witness.SetSubscription(subscription); + witness.OnNext(1); + InvalidOperationException error = new("source"); + + witness.OnError(error); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/KeepNotNullWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/KeepNotNullWitnessTests.cs new file mode 100644 index 00000000..e0ec692f --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/KeepNotNullWitnessTests.cs @@ -0,0 +1,42 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests the non-null filter's failure handling. +public class KeepNotNullWitnessTests +{ + /// A failed value callback propagates its exception and releases the source. + /// A task representing the asynchronous test. + [Test] + public async Task OnNext_ObserverThrows_DisposesSubscription() + { + RecordingDisposable subscription = new(); + using KeepNotNullWitness witness = new(new ThrowingWitness(throwOnNext: true)); + witness.SetSubscription(subscription); + + await Assert.That(() => witness.OnNext("value")).ThrowsExactly(); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } + + /// Errors pass through the filter and release the subscription. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_ForwardsErrorAndDisposesSubscription() + { + RecordingWitness observer = new(); + RecordingDisposable subscription = new(); + using KeepNotNullWitness witness = new(observer); + witness.SetSubscription(subscription); + InvalidOperationException error = new("source"); + + witness.OnError(error); + + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/KeepTypeWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/KeepTypeWitnessTests.cs new file mode 100644 index 00000000..5b262208 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/KeepTypeWitnessTests.cs @@ -0,0 +1,42 @@ +// 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.Primitives.Advanced; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests the type filter's failure handling. +public class KeepTypeWitnessTests +{ + /// A failed value callback propagates its exception and releases the source. + /// A task representing the asynchronous test. + [Test] + public async Task OnNext_ObserverThrows_DisposesSubscription() + { + RecordingDisposable subscription = new(); + using KeepTypeWitness witness = new(new ThrowingWitness(throwOnNext: true)); + witness.SetSubscription(subscription); + + await Assert.That(() => witness.OnNext(1)).ThrowsExactly(); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } + + /// Errors pass through the filter and release the subscription. + /// A task representing the asynchronous test. + [Test] + public async Task OnError_ForwardsErrorAndDisposesSubscription() + { + RecordingWitness observer = new(); + RecordingDisposable subscription = new(); + using KeepTypeWitness witness = new(observer); + witness.SetSubscription(subscription); + InvalidOperationException error = new("source"); + + witness.OnError(error); + + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + await Assert.That(subscription.DisposeCount).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.SubscriptionDisposal.cs b/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.SubscriptionDisposal.cs new file mode 100644 index 00000000..f67bb4fb --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.SubscriptionDisposal.cs @@ -0,0 +1,146 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests disposal during scheduled operator callbacks. +public partial class LinqExtensionsTests +{ + /// Disposal before an accepted inner subscription starts prevents subscribing to that source. + /// A task representing the asynchronous test. + [Test] + public async Task FlatMap_DisposedBeforeInnerSubscribe_DoesNotAttachInnerSource() + { + using Signal source = new(); + using Signal inner = new(); + RecordingWitness observer = new(); + LinqExtensions.FlatMapCoordinator coordinator = new(source, _ => inner, observer); + using var subscription = coordinator.Run(); + + coordinator.Dispose(); + coordinator.SubscribeInner(inner); + inner.OnNext(1); + + await Assert.That(source.HasObservers).IsFalse(); + await Assert.That(inner.HasObservers).IsFalse(); + await Assert.That(observer.Values.Count).IsEqualTo(0); + } + + /// A probe disposes the timer returned after an inline callback has already disposed the subscription. + /// A task representing the asynchronous test. + [Test] + public async Task Probe_DisposedDuringInlineTick_DisposesLateTimerHandle() + { + const int IgnoredValue = 2; + InlineProbeSequencer sequencer = new(); + using Signal source = new(); + List values = []; + IDisposable? subscription = null; + subscription = source.Probe(TimeSpan.Zero, sequencer).Subscribe(value => + { + values.Add(value); + subscription!.Dispose(); + }); + using var cleanup = subscription; + + source.OnNext(1); + source.OnNext(IgnoredValue); + + await Assert.That(values.SequenceEqual([1])).IsTrue(); + await Assert.That(source.HasObservers).IsFalse(); + await Assert.That(sequencer.ScheduledItemDisposed).IsTrue(); + } + + /// A terminal callback during a timer's clock read prevents the captured value from following the error. + /// A task representing the asynchronous test. + [Test] + public async Task Calm_SourceErrorsDuringClockRead_DropsCapturedValue() + { + ClockCallbackSequencer sequencer = new(); + using Signal source = new(); + RecordingWitness observer = new(); + using var subscription = source.Calm(TimeSpan.Zero, sequencer).Subscribe(observer); + source.OnNext(1); + InvalidOperationException error = new("source"); + sequencer.OnClockRead = () => source.OnError(error); + + sequencer.RunPending(); + + await Assert.That(observer.Values.Count).IsEqualTo(0); + await Assert.That(observer.Errors.Single()).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + } + + /// Runs the scheduled probe immediately and retains its cancellation state for inspection. + private sealed class InlineProbeSequencer : ISequencer + { + /// The item executed inline. + private IWorkItem? _item; + + /// + public DateTimeOffset Now => DateTimeOffset.UnixEpoch; + + /// + public long Timestamp => 0; + + /// Gets whether the executed item was cancelled after scheduling returned. + internal bool ScheduledItemDisposed => _item is IsDisposed { IsDisposed: true }; + + /// + public void Schedule(IWorkItem item) + { + _item = item; + item.Execute(); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Schedule(IWorkItem item, long dueTimestamp) => Schedule(item); + } + + /// Runs a source terminal callback during the next clock read inside a manually driven timer. + private sealed class ClockCallbackSequencer : ISequencer + { + /// The timer waiting for explicit execution. + private IWorkItem? _pending; + + /// + public DateTimeOffset Now + { + get + { + var callback = OnClockRead; + OnClockRead = null; + callback?.Invoke(); + return DateTimeOffset.UnixEpoch; + } + } + + /// + public long Timestamp => 0; + + /// Gets or sets the callback run on the next clock read. + internal Action? OnClockRead { get; set; } + + /// + public void Schedule(IWorkItem item) => _pending = item; + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Schedule(IWorkItem item, long dueTimestamp) => Schedule(item); + + /// Executes the pending timer once. + internal void RunPending() + { + var item = _pending; + _pending = null; + item!.Execute(); + } + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.cs index 5cd7c3db..29b0ef57 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/LinqExtensionsTests.cs @@ -9,6 +9,27 @@ namespace ReactiveUI.Primitives.Tests; /// Verifies miscellaneous Primitives extension contracts. public partial class LinqExtensionsTests { + /// The wrapper owns its disposable and invokes its optional callback before releasing it. + /// Whether the wrapper includes a disposal callback. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DisposeWith_WhenDisposedTwice_ThenReleasesTheResourceOnce(bool withCallback) + { + const string disposalEvent = "dispose"; + List events = []; + ActionDisposable resource = new(() => events.Add(disposalEvent)); + var wrapper = withCallback + ? resource.DisposeWith(() => events.Add("callback")) + : resource.DisposeWith(); + + wrapper.Dispose(); + wrapper.Dispose(); + + await Assert.That(events.SequenceEqual(withCallback ? ["callback", disposalEvent] : [disposalEvent])).IsTrue(); + } + /// Verifies DisposeWith preserves the concrete type and tracks the original disposable. /// A task representing the asynchronous operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs index 2adea242..7a49592f 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/LoopSignalTests.cs @@ -2,6 +2,8 @@ // 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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Signals; namespace ReactiveUI.Primitives.Tests; @@ -31,4 +33,24 @@ public async Task LoopBoundedByTakeRepeatsTheValueAndStops() await Assert.That(values.SequenceEqual(ExpectedValues)).IsTrue(); await Assert.That(completions).IsEqualTo(1); } + + /// A direct subscription stops recursion when its observer disposes it during the first value. + /// A task representing the asynchronous test. + [Test] + public async Task Subscribe_DisposedDuringValue_StopsRecursiveLoop() + { + List values = []; + IDisposable? subscription = null; + using var scheduled = Sequencer.CurrentThread.Schedule(() => + { + subscription = new LoopSignal(RepeatedValue).Subscribe(Witness.Create(value => + { + values.Add(value); + subscription!.Dispose(); + })); + }); + using var cleanup = subscription; + + await Assert.That(values.SequenceEqual([RepeatedValue])).IsTrue(); + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/ObserverHandlerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ObserverHandlerTests.cs new file mode 100644 index 00000000..5b129057 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/ObserverHandlerTests.cs @@ -0,0 +1,33 @@ +// 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.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests observer detachment from an asynchronous signal. +public class ObserverHandlerTests +{ + /// A subscription handle can be disposed repeatedly without detaching another observer. + /// A task representing the asynchronous test. + [Test] + public async Task Dispose_Repeated_PreservesOtherSubscriptions() + { + using AsyncSignal signal = new(); + RecordingWitness detached = new(); + RecordingWitness remaining = new(); + var first = signal.Subscribe(detached); + using var second = signal.Subscribe(remaining); + + first.Dispose(); + first.Dispose(); + signal.OnNext(1); + signal.OnCompleted(); + + await Assert.That(detached.Values.Count).IsEqualTo(0); + await Assert.That(detached.Completed).IsEqualTo(0); + await Assert.That(remaining.Values.Single()).IsEqualTo(1); + await Assert.That(remaining.Completed).IsEqualTo(1); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs new file mode 100644 index 00000000..135f7cb4 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs @@ -0,0 +1,62 @@ +// 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.Runtime.CompilerServices; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests read-only state mirroring. +public class ReadOnlyStateTests +{ + /// The changed stream replays the initial state and forwards subsequent updates. + /// A task representing the asynchronous test. + [Test] + public async Task Changed_ReplaysInitialValueAndTracksSource() + { + const int UpdatedValue = 2; + using Signal source = new(); + using ReadOnlyState state = new(source, 1); + RecordingWitness observer = new(); + using var subscription = state.Changed.Subscribe(observer); + + source.OnNext(UpdatedValue); + + await Assert.That(observer.Values.SequenceEqual([1, UpdatedValue])).IsTrue(); + await Assert.That(state.Value).IsEqualTo(UpdatedValue); + } + + /// The debugger display leaves the cached value intact. + /// A task representing the asynchronous test. + [Test] + public async Task DebuggerDisplay_PreservesCachedValue() + { + using Signal source = new(); + using ReadOnlyState state = new(source, 1); + await Assert.That(GetDebuggerDisplay(state)).IsEqualTo(state.ToString()); + await Assert.That(state.Value).IsEqualTo(1); + } + + /// Invokes the getter used by the debugger without reflection. + /// The instance to display. + /// The debugger display text. +#if NET9_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string GetDebuggerDisplay(ReadOnlyState state) => DebuggerAccessor.Read(state); + + /// Matches the target type's generic context required by .NET 9 and later. + /// The state's value type. + private static class DebuggerAccessor + { + /// Invokes the getter evaluated by the debugger. + /// The state to display. + /// The debugger text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + internal static extern string Read(ReadOnlyState state); + } +#else + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string GetDebuggerDisplay(ReadOnlyState state); +#endif +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/RxVoidTests.cs b/src/tests/ReactiveUI.Primitives.Tests/RxVoidTests.cs new file mode 100644 index 00000000..8dab8683 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/RxVoidTests.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 System.Runtime.CompilerServices; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests the unit value's debugger representation. +public sealed class RxVoidTests +{ + /// The debugger renders the unit value using its normal textual representation. + /// The test operation. + [Test] + public async Task DebuggerDisplay_WhenDefault_ThenShowsUnit() + { + var value = RxVoid.Default; + + await Assert.That(ReadDebuggerDisplay(ref value)).IsEqualTo("()"); + } + + /// Reads the property evaluated by the debugger. + /// The unit value. + /// The debugger representation. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string ReadDebuggerDisplay(ref RxVoid value); +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs new file mode 100644 index 00000000..638454ed --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs @@ -0,0 +1,45 @@ +// 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.Runtime.CompilerServices; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests the scheduled queue's debugger representation. +public sealed class SequencerQueueTests +{ + /// The debugger can render an empty queue without inspecting any scheduled items. + /// The test operation. + [Test] + public async Task DebuggerDisplay_WhenEmpty_ThenShowsTheQueue() + { + SequencerQueue queue = new(); + + await Assert.That(ReadDebuggerDisplay(queue)).IsEqualTo(queue.ToString()); + } + + /// Reads the property evaluated by the debugger. + /// The scheduled queue. + /// The debugger representation. +#if NET9_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string ReadDebuggerDisplay(SequencerQueue queue) => DebuggerAccessor.Read(queue); + + /// Matches the target type's generic context required by .NET 9 and later. + /// The queue's absolute time type. + private static class DebuggerAccessor + where T : IComparable + { + /// Invokes the getter evaluated by the debugger. + /// The scheduled queue. + /// The debugger text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + internal static extern string Read(SequencerQueue queue); + } +#else + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string ReadDebuggerDisplay(SequencerQueue queue); +#endif +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Durations.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Durations.cs new file mode 100644 index 00000000..5f5e28de --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.Durations.cs @@ -0,0 +1,38 @@ +// 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.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests monotonic duration conversion. +public partial class SequencerTests +{ + /// The largest timestamp duration saturates when it exceeds TimeSpan's range. + /// The test operation. + [Test] + public async Task ToTimeSpanDelta_WhenTimestampIsMaximum_ThenClampsToTheDurationRange() + { + var ticks = decimal.Ceiling(((decimal)long.MaxValue * TimeSpan.TicksPerSecond) / System.Diagnostics.Stopwatch.Frequency); + var expected = TimeSpan.FromTicks((long)Math.Min(ticks, TimeSpan.MaxValue.Ticks)); + + await Assert.That(Sequencer.ToTimeSpanDelta(long.MaxValue)).IsEqualTo(expected); + } + + /// The absolute action overload queues work until its virtual deadline. + /// The test operation. + [Test] + public async Task Schedule_WhenAbsoluteDeadlineArrives_ThenRunsTheAction() + { + VirtualClock clock = new(); + ISequencer sequencer = clock; + List pending = [1]; + var due = clock.Now.AddTicks(1); + using var subscription = sequencer.Schedule(due, pending.Clear); + + await Assert.That(pending.Count).IsEqualTo(1); + clock.AdvanceTo(due); + await Assert.That(pending.Count).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs index e387c2d7..ff8a1ccf 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.WorkItems.cs @@ -60,6 +60,7 @@ public async Task ActionWorkItemDoesNotRunAfterCancellation() item.Execute(); item.Dispose(); + item.Dispose(); item.Execute(); await Assert.That(ran).IsEqualTo(1); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalExtensionsTests.cs new file mode 100644 index 00000000..502ffc24 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalExtensionsTests.cs @@ -0,0 +1,45 @@ +// 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.Primitives.Signals; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests task cancellation and result delivery. +public sealed class SignalExtensionsTests +{ + /// The action-free overload consumes cancellation from a task without a result. + /// The test operation. + [Test] + public async Task HandleCancellation_WhenTaskIsCanceled_ThenCompletesNormally() + { + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + var task = Task.FromCanceled(cancellation.Token); + + var handled = task.HandleCancellation(); + await handled; + + await Assert.That(handled.IsCompletedSuccessfully).IsTrue(); + } + + /// Cancellation ends the wait while the original task remains free to finish later. + /// The test operation. + [Test] + public async Task WhenCancelled_WhenTokenWins_ThenPreservesTheTokenAndOriginalTask() + { + const int result = 7; + using CancellationTokenSource cancellation = new(); + TaskCompletionSource source = new(TaskCreationOptions.RunContinuationsAsynchronously); + var waiting = source.Task.WhenCancelled(cancellation.Token); + + await cancellation.CancelAsync(); + var error = await Assert.That(async () => await waiting).Throws(); + + await Assert.That(error!.CancellationToken).IsEqualTo(cancellation.Token); + await Assert.That(source.Task.IsCompleted).IsFalse(); + source.SetResult(result); + await Assert.That(await source.Task).IsEqualTo(result); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs index 40be6267..733fc91a 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.DefaultSequencer.cs @@ -91,4 +91,27 @@ public async Task EveryTicksWhenTheSequencerRuns() sequencer.RunPending(); await Assert.That(witness.Values.SequenceEqual([0L, 1L])).IsTrue(); } + + /// The recurring factory without a scheduler creates a lazy thread-pool signal. + /// A task representing the asynchronous test. + [Test] + public async Task Every_WithoutScheduler_CreatesLazyRecurringSignal() + { + var signal = await Assert.That(Signal.Every(ShortExpiry)).IsTypeOf().And.IsNotNull(); + + await Assert.That(signal.IsRequiredSubscribeOnCurrentThread()).IsFalse(); + } + + /// The expiry factory without a scheduler validates its source and preserves lazy subscription. + /// A task representing the asynchronous test. + [Test] + public async Task Expire_WithoutScheduler_CreatesLazyTimeoutSignal() + { + using Signal source = new(); + var signal = await Assert.That(Signal.Expire(source, ShortExpiry)).IsTypeOf>().And.IsNotNull(); + + await Assert.That(signal.IsRequiredSubscribeOnCurrentThread()).IsFalse(); + await Assert.That(source.HasObservers).IsFalse(); + await Assert.That(static () => Signal.Expire(null!, ShortExpiry)).ThrowsExactly(); + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs index 3252fc17..55733658 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs @@ -34,6 +34,17 @@ public class StateSignalTests /// Expected projected read-only state values. private static readonly string[] ExpectedReadOnlyValues = ["v:10", "v:11", "v:11"]; + /// The debugger display leaves the latest value and observer set intact. + /// A task representing the asynchronous test. + [Test] + public async Task DebuggerDisplay_PreservesSignalState() + { + using StateSignal signal = new(InitialStateValue); + await Assert.That(GetDebuggerDisplay(signal)).IsEqualTo(signal.ToString()); + await Assert.That(signal.Value).IsEqualTo(InitialStateValue); + await Assert.That(signal.HasObservers).IsFalse(); + } + /// A read-only projection rejects a null error. [Test] public void ReadOnlyStateProjectionValidatesError() @@ -223,6 +234,28 @@ public async Task DisposedReadOnlyStateProjectionRejectsReadsAndSubscriptions() await Assert.That(source.Value).IsEqualTo(UpdatedStateValue); } + /// Invokes the getter used by the debugger without reflection. + /// The instance to display. + /// The debugger display text. +#if NET9_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string GetDebuggerDisplay(StateSignal signal) => DebuggerAccessor.Read(signal); + + /// Matches the target type's generic context required by .NET 9 and later. + /// The signal's value type. + private static class DebuggerAccessor + { + /// Invokes the getter evaluated by the debugger. + /// The signal to display. + /// The debugger text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + internal static extern string Read(StateSignal signal); + } +#else + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string GetDebuggerDisplay(StateSignal signal); +#endif + /// Records observer notifications. /// The observed value type. private sealed class Recorder : IObserver diff --git a/src/tests/ReactiveUI.Primitives.Tests/SynchronizedSequencerQueueTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SynchronizedSequencerQueueTests.cs new file mode 100644 index 00000000..040c7015 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/SynchronizedSequencerQueueTests.cs @@ -0,0 +1,30 @@ +// 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.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests cancellation-aware queue traversal. +public sealed class SynchronizedSequencerQueueTests +{ + /// Cancelled head entries are discarded while live work remains queued. + /// The test operation. + [Test] + public async Task GetNextLive_WhenHeadIsCanceled_ThenSkipsItWithoutRemovingLiveWork() + { + SynchronizedSequencerQueue queue = new(); + var canceled = ScheduledItem.Create(Sequencer.Immediate, 0, static (_, _) => EmptyDisposable.Instance, 0); + var live = ScheduledItem.Create(Sequencer.Immediate, 0, static (_, _) => EmptyDisposable.Instance, 1); + queue.Enqueue(canceled); + queue.Enqueue(live); + canceled.Cancel(); + + await Assert.That(queue.GetNextLive()).IsSameReferenceAs(live); + await Assert.That(queue.GetNextLive()).IsSameReferenceAs(live); + queue.Remove(live); + await Assert.That(queue.GetNextLive()).IsNull(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/ThrowSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ThrowSignalTests.cs new file mode 100644 index 00000000..c65bd6b3 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/ThrowSignalTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests scheduled error delivery. +public sealed class ThrowSignalTests +{ + /// An immediate error is forwarded inline without a second terminal notification. + /// The test operation. + [Test] + public async Task Subscribe_WhenImmediate_ThenForwardsTheErrorInline() + { + InvalidOperationException error = new("source failure"); + ThrowSignal source = new(error, Sequencer.Immediate); + RecordingWitness observer = new(); + + using var subscription = source.Subscribe(observer); + + await Assert.That(source.IsRequiredSubscribeOnCurrentThread()).IsFalse(); + await Assert.That(observer.Errors.Count).IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(error); + await Assert.That(observer.Completed).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/TimeIntervalWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/TimeIntervalWitnessTests.cs new file mode 100644 index 00000000..33208c88 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/TimeIntervalWitnessTests.cs @@ -0,0 +1,32 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Core; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests timestamped notification teardown. +public sealed class TimeIntervalWitnessTests +{ + /// A source error is forwarded unchanged and releases its subscription. + /// The test operation. + [Test] + public async Task OnError_WhenSubscribed_ThenForwardsTheErrorAndDisposes() + { + RecordingWitness> observer = new(); + using TimeIntervalWitness witness = new(observer, new VirtualClock()); + BooleanDisposable subscription = new(); + InvalidOperationException error = new("source failure"); + witness.SetSubscription(subscription); + + witness.OnError(error); + + await Assert.That(observer.Errors.Count).IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(error); + await Assert.That(subscription.IsDisposed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/UnsparkWitnessTests.cs b/src/tests/ReactiveUI.Primitives.Tests/UnsparkWitnessTests.cs new file mode 100644 index 00000000..d3329b38 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/UnsparkWitnessTests.cs @@ -0,0 +1,30 @@ +// 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.Primitives.Advanced; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests dematerialized notification teardown. +public sealed class UnsparkWitnessTests +{ + /// An error in the notification source is forwarded and releases its subscription. + /// The test operation. + [Test] + public async Task OnError_WhenSubscribed_ThenForwardsTheErrorAndDisposes() + { + RecordingWitness observer = new(); + using UnsparkWitness witness = new(observer); + BooleanDisposable subscription = new(); + InvalidOperationException error = new("source failure"); + witness.SetSubscription(subscription); + + witness.OnError(error); + + await Assert.That(observer.Errors.Count).IsEqualTo(1); + await Assert.That(observer.Errors[0]).IsSameReferenceAs(error); + await Assert.That(subscription.IsDisposed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Primitives.Tests/VirtualTimeSequencerExtensionsTests.cs b/src/tests/ReactiveUI.Primitives.Tests/VirtualTimeSequencerExtensionsTests.cs new file mode 100644 index 00000000..4b208e03 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.Tests/VirtualTimeSequencerExtensionsTests.cs @@ -0,0 +1,30 @@ +// 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.Primitives.Concurrency; + +namespace ReactiveUI.Primitives.Tests; + +/// Tests action scheduling through the generic virtual-time interface. +public sealed class VirtualTimeSequencerExtensionsTests +{ + /// Both generic overloads deliver actions at the requested virtual time. + /// Whether to use the absolute-time overload. + /// The test operation. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Schedule_WhenDeadlineArrives_ThenInvokesTheAction(bool absolute) + { + var sequencer = MinimalVirtualClock.Create(); + List pending = [1]; + using var subscription = absolute + ? sequencer.ScheduleAbsolute(1L, pending.Clear) + : sequencer.ScheduleRelative(1L, pending.Clear); + + await Assert.That(pending.Count).IsEqualTo(1); + sequencer.AdvanceBy(1L); + await Assert.That(pending.Count).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs index 65079628..3f0c0ca9 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs @@ -22,6 +22,18 @@ public sealed class ControlSequencerTests public async Task ConstructorRejectsNullControl() => await Assert.That(static () => new ControlSequencer(null!)).ThrowsExactly(); + /// The public constructor retains the control without creating its native window handle. + /// A task representing the asynchronous test. + [Test] + public async Task Constructor_RetainsControlWithoutCreatingHandle() + { + using var control = CreateControl(); + ControlSequencer scheduler = new(control); + + await Assert.That(scheduler.Control).IsSameReferenceAs(control); + await Assert.That(control.IsHandleCreated).IsFalse(); + } + /// Work rejected before handle creation is retried in order when the handle becomes ready. /// The test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs index ca1b0fee..d5a8b5da 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs @@ -22,6 +22,25 @@ public sealed class ControlSequencerTests public async Task ConstructorRejectsNullControl() => await Assert.That(static () => new ControlSequencer(null!)).ThrowsExactly(); + /// The public constructor retains the control and exposes its clock without creating a handle. + /// A task representing the asynchronous test. + [Test] + public async Task Constructor_RetainsControlWithoutCreatingHandle() + { + using var control = CreateControl(); + ControlSequencer sequencer = new(control); + var before = System.Diagnostics.Stopwatch.GetTimestamp(); + var timestamp = sequencer.Timestamp; + var after = System.Diagnostics.Stopwatch.GetTimestamp(); + + await Assert.That(sequencer.Control).IsSameReferenceAs(control); + await Assert.That(control.IsHandleCreated).IsFalse(); + await Assert.That(sequencer.Now.Offset).IsEqualTo(TimeSpan.Zero); + await Assert.That(timestamp).IsGreaterThanOrEqualTo(before); + await Assert.That(timestamp).IsLessThanOrEqualTo(after); + await Assert.That(GetDebuggerDisplay(sequencer)).IsEqualTo(sequencer.ToString()); + } + /// Work rejected before handle creation is retried in order when the handle becomes ready. /// The test operation. [Test] @@ -163,6 +182,12 @@ public async Task DelayedScheduleWaitsForItsCallback(bool cancel) await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); } + /// Invokes the getter used by the debugger without reflection. + /// The sequencer to display. + /// The debugger display text. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] + private static extern string GetDebuggerDisplay(ControlSequencer sequencer); + /// Creates a control without changing the caller's synchronization context. /// The control. private static Control CreateControl() diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs index bf09a77b..ca82101c 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs @@ -32,6 +32,29 @@ public async Task ConstructorRejectsNullDispatcherQueue() .ThrowsExactly(); } + /// Public constructors retain a live queue and the selected priority. + /// A task representing the asynchronous test. + [Test] + public async Task Constructors_RetainDispatcherQueueAndPriority() + { + var controller = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + var queue = controller.DispatcherQueue; + DispatcherQueueSequencer defaults = new(queue); + DispatcherQueueSequencer selected = new(queue, DispatcherQueuePriority.High); + + await Assert.That(defaults.DispatcherQueue).IsSameReferenceAs(queue); + await Assert.That(defaults.Priority).IsEqualTo(DispatcherQueuePriority.Normal); + await Assert.That(selected.DispatcherQueue).IsSameReferenceAs(queue); + await Assert.That(selected.Priority).IsEqualTo(DispatcherQueuePriority.High); + } + finally + { + await controller.ShutdownQueueAsync(); + } + } + /// Queued actions preserve order and cancellation until the drain runs. /// The test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs index 658a1383..8f903a21 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs @@ -33,6 +33,34 @@ public async Task ConstructorRejectsNullDispatcherQueue() .ThrowsExactly(); } + /// Public constructors retain a live queue and the selected priority. + /// A task representing the asynchronous test. + [Test] + public async Task Constructors_RetainDispatcherQueueAndPriority() + { + var controller = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + var queue = controller.DispatcherQueue; + DispatcherQueueSequencer defaults = new(queue); + DispatcherQueueSequencer selected = new(queue, DispatcherQueuePriority.High); + var before = System.Diagnostics.Stopwatch.GetTimestamp(); + var timestamp = selected.Timestamp; + var after = System.Diagnostics.Stopwatch.GetTimestamp(); + + await Assert.That(defaults.DispatcherQueue).IsSameReferenceAs(queue); + await Assert.That(defaults.Priority).IsEqualTo(DispatcherQueuePriority.Normal); + await Assert.That(selected.DispatcherQueue).IsSameReferenceAs(queue); + await Assert.That(selected.Priority).IsEqualTo(DispatcherQueuePriority.High); + await Assert.That(timestamp).IsGreaterThanOrEqualTo(before); + await Assert.That(timestamp).IsLessThanOrEqualTo(after); + } + finally + { + await controller.ShutdownQueueAsync(); + } + } + /// Queued work preserves order and cancellation until the drain runs. /// The test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs index ee1bedec..ebd787c7 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs @@ -41,6 +41,20 @@ public async Task ConstructorRetainsDispatcherAndPriority() await Assert.That(sequencer.DebuggerDisplay).IsEqualTo(typeof(DispatcherSequencer).FullName); } + /// The dispatcher sequencer shares the monotonic timestamp scale used by scheduled work. + /// A task representing the asynchronous test. + [Test] + public async Task Timestamp_UsesSharedSequencerClock() + { + DispatcherSequencer sequencer = new(Dispatcher.CurrentDispatcher); + var before = System.Diagnostics.Stopwatch.GetTimestamp(); + var timestamp = sequencer.Timestamp; + var after = System.Diagnostics.Stopwatch.GetTimestamp(); + + await Assert.That(timestamp).IsGreaterThanOrEqualTo(before); + await Assert.That(timestamp).IsLessThanOrEqualTo(after); + } + /// A posted batch preserves order and skips cancelled work. /// The test operation. [Test] From 38138a2b0e93db240211151d752ed8e4c926a7bb Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:39:56 +1000 Subject: [PATCH 11/11] Fix build issue --- src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs | 5 ++++- src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs | 5 ++++- src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs | 5 ++++- src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs index 36509ef5..27013882 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BehaviorSignalTests.cs @@ -265,10 +265,10 @@ public async Task Subscribe_ReentrantOnNext_FollowsTheInitialValue() await Assert.That(values.SequenceEqual([0, 1])).IsTrue(); } +#if NET9_0_OR_GREATER /// Invokes the getter used by the debugger without reflection. /// The instance to display. /// The debugger display text. -#if NET9_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining)] private static string? GetDebuggerDisplay(BehaviorSignal signal) => DebuggerAccessor.Read(signal); @@ -283,6 +283,9 @@ private static class DebuggerAccessor internal static extern string? Read(BehaviorSignal signal); } #else + /// Invokes the getter used by the debugger without reflection. + /// The instance to display. + /// The debugger display text. [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] private static extern string? GetDebuggerDisplay(BehaviorSignal signal); #endif diff --git a/src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs b/src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs index 135f7cb4..4b9fea5e 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/ReadOnlyStateTests.cs @@ -38,10 +38,10 @@ public async Task DebuggerDisplay_PreservesCachedValue() await Assert.That(state.Value).IsEqualTo(1); } +#if NET9_0_OR_GREATER /// Invokes the getter used by the debugger without reflection. /// The instance to display. /// The debugger display text. -#if NET9_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining)] private static string GetDebuggerDisplay(ReadOnlyState state) => DebuggerAccessor.Read(state); @@ -56,6 +56,9 @@ private static class DebuggerAccessor internal static extern string Read(ReadOnlyState state); } #else + /// Invokes the getter used by the debugger without reflection. + /// The instance to display. + /// The debugger display text. [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] private static extern string GetDebuggerDisplay(ReadOnlyState state); #endif diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs index 638454ed..ee7d125d 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerQueueTests.cs @@ -20,10 +20,10 @@ public async Task DebuggerDisplay_WhenEmpty_ThenShowsTheQueue() await Assert.That(ReadDebuggerDisplay(queue)).IsEqualTo(queue.ToString()); } +#if NET9_0_OR_GREATER /// Reads the property evaluated by the debugger. /// The scheduled queue. /// The debugger representation. -#if NET9_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining)] private static string ReadDebuggerDisplay(SequencerQueue queue) => DebuggerAccessor.Read(queue); @@ -39,6 +39,9 @@ private static class DebuggerAccessor internal static extern string Read(SequencerQueue queue); } #else + /// Reads the property evaluated by the debugger. + /// The scheduled queue. + /// The debugger representation. [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] private static extern string ReadDebuggerDisplay(SequencerQueue queue); #endif diff --git a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs index 55733658..8f6ca1d6 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StateSignalTests.cs @@ -234,10 +234,10 @@ public async Task DisposedReadOnlyStateProjectionRejectsReadsAndSubscriptions() await Assert.That(source.Value).IsEqualTo(UpdatedStateValue); } +#if NET9_0_OR_GREATER /// Invokes the getter used by the debugger without reflection. /// The instance to display. /// The debugger display text. -#if NET9_0_OR_GREATER [MethodImpl(MethodImplOptions.AggressiveInlining)] private static string GetDebuggerDisplay(StateSignal signal) => DebuggerAccessor.Read(signal); @@ -252,6 +252,9 @@ private static class DebuggerAccessor internal static extern string Read(StateSignal signal); } #else + /// Invokes the getter used by the debugger without reflection. + /// The instance to display. + /// The debugger display text. [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_DebuggerDisplay")] private static extern string GetDebuggerDisplay(StateSignal signal); #endif