From 5c152d844cd519b06ef6f9d3973c5b28d7ca8b66 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Mon, 4 May 2026 14:06:18 +0100 Subject: [PATCH 1/2] Fix interaction async handler scheduling --- src/ReactiveUI/Interactions/Interaction.cs | 49 ++++++- .../ReactiveUI.Tests/InteractionsTest.cs | 131 +++++++++++++----- 2 files changed, 142 insertions(+), 38 deletions(-) diff --git a/src/ReactiveUI/Interactions/Interaction.cs b/src/ReactiveUI/Interactions/Interaction.cs index 97a633f63b..f050ae24bf 100644 --- a/src/ReactiveUI/Interactions/Interaction.cs +++ b/src/ReactiveUI/Interactions/Interaction.cs @@ -87,11 +87,13 @@ public IDisposable RegisterHandler(Action> { ArgumentExceptionHelper.ThrowIfNull(handler); - return RegisterHandler(interaction => + IObservable ContentHandler(IInteractionContext interaction) { handler(interaction); return Observables.Unit; - }); + } + + return RegisterHandlerCore(ContentHandler); } /// @@ -99,7 +101,15 @@ public IDisposable RegisterHandler(Func, Ta { ArgumentExceptionHelper.ThrowIfNull(handler); - return RegisterHandler(interaction => handler(interaction).ToObservable()); + IObservable ContentHandler(IInteractionContext interaction) => + Observable.FromAsync( + async () => + { + await YieldToCurrentContext(); + await handler(interaction).ConfigureAwait(false); + }); + + return RegisterHandlerCore(ContentHandler); } /// @@ -107,10 +117,16 @@ public IDisposable RegisterHandler(Func ContentHandler(IInteractionContext context) => handler(context).Select(_ => Unit.Default); + IObservable ContentHandler(IInteractionContext context) => + Observable.FromAsync( + async () => + { + await YieldToCurrentContext(); + return handler(context); + }) + .SelectMany(result => result.Select(_ => Unit.Default)); - AddHandler(ContentHandler); - return Disposable.Create(() => RemoveHandler(ContentHandler)); + return RegisterHandlerCore(ContentHandler); } /// @@ -153,6 +169,27 @@ protected Func, IObservable>[] GetHan /// The interaction context. protected virtual IOutputContext GenerateContext(TInput input) => new InteractionContext(input); + /// + /// Yields once so asynchronous handlers are not invoked inside the current scheduler trampoline. + /// + /// A task that completes after the current context has yielded. + private static async Task YieldToCurrentContext() + { + await Task.Yield(); + } + + /// + /// Registers a normalized interaction handler. + /// + /// The normalized handler. + /// A disposable which unregisters the handler. + private IDisposable RegisterHandlerCore(Func, IObservable> contentHandler) + { + ArgumentExceptionHelper.ThrowIfNull(contentHandler); + AddHandler(contentHandler); + return Disposable.Create(() => RemoveHandler(contentHandler)); + } + /// /// Adds a handler delegate to be invoked for interaction contexts. /// diff --git a/src/tests/ReactiveUI.Tests/InteractionsTest.cs b/src/tests/ReactiveUI.Tests/InteractionsTest.cs index 726f8117b5..8446411e39 100644 --- a/src/tests/ReactiveUI.Tests/InteractionsTest.cs +++ b/src/tests/ReactiveUI.Tests/InteractionsTest.cs @@ -10,6 +10,7 @@ namespace ReactiveUI.Tests; /// /// Tests interactions. /// +[NotInParallel] public class InteractionsTest { /// @@ -23,8 +24,8 @@ public async Task AttemptingToGetInteractionOutputBeforeItHasBeenSetShouldCauseE interaction.RegisterHandler(context => { _ = ((InteractionContext)context).GetOutput(); }); - var ex = Assert.Throws(() => interaction.Handle(Unit.Default).Subscribe()); - await Assert.That(ex.Message).IsEqualTo("Output has not been set."); + var ex = await Assert.ThrowsAsync(() => interaction.Handle(Unit.Default).ToTask()); + await Assert.That(ex!.Message).IsEqualTo("Output has not been set."); } /// @@ -42,20 +43,21 @@ public async Task AttemptingToSetInteractionOutputMoreThanOnceShouldCauseExcepti context.SetOutput(Unit.Default); }); - var ex = Assert.Throws(() => interaction.Handle(Unit.Default).Subscribe()); - await Assert.That(ex.Message).IsEqualTo("Output has already been set."); + var ex = await Assert.ThrowsAsync(() => interaction.Handle(Unit.Default).ToTask()); + await Assert.That(ex!.Message).IsEqualTo("Output has already been set."); } /// /// Tests that Handled interactions should not cause exception. /// + /// A representing the asynchronous operation. [Test] - public void HandledInteractionsShouldNotCauseException() + public async Task HandledInteractionsShouldNotCauseException() { var interaction = new Interaction(); interaction.RegisterHandler(static c => c.SetOutput(true)); - interaction.Handle(Unit.Default).FirstAsync().Wait(); + await interaction.Handle(Unit.Default); } /// @@ -95,16 +97,27 @@ public async Task HandlersCanContainAsynchronousCode() // even though handler B is "slow" (i.e. mimicks waiting for the user), it takes precedence over A, so we expect A to never even be called var handler1AWasCalled = false; + var handler1BWasSubscribed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var handler1A = interaction.RegisterHandler(x => { x.SetOutput("A"); handler1AWasCalled = true; }); var handler1B = interaction.RegisterHandler(x => - Observables - .Unit - .Delay(TimeSpan.FromSeconds(1), scheduler) - .Do(_ => x.SetOutput("B"))); + { + return Observable.Create( + observer => + { + var subscription = Observables + .Unit + .Delay(TimeSpan.FromSeconds(1), scheduler) + .Do(_ => x.SetOutput("B")) + .Subscribe(observer); + + handler1BWasSubscribed.TrySetResult(); + return subscription; + }); + }); using (handler1A) using (handler1B) @@ -113,6 +126,7 @@ public async Task HandlersCanContainAsynchronousCode() .Handle(Unit.Default) .ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var result).Subscribe(); + await handler1BWasSubscribed.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Assert.That(result).IsEmpty(); scheduler.AdvanceBy(TimeSpan.FromSeconds(0.5)); await Assert.That(result).IsEmpty(); @@ -139,14 +153,67 @@ public async Task HandlersCanContainAsynchronousCodeViaTasks() return Task.FromResult(true); }); - string? result = null; - interaction - .Handle(Unit.Default) - .Subscribe(r => result = r); + var result = await interaction.Handle(Unit.Default); await Assert.That(result).IsEqualTo("result"); } + /// + /// Tests that task handlers release the current scheduler before invoking user code. + /// + /// A representing the asynchronous operation. + [Test] + public async Task TaskHandlersShouldNotBlockNestedInteractionsBeforeReturningTask() + { + var parent = new Interaction(); + var nested = new Interaction(); + var nestedHandledBeforeParentReturned = false; + string? nestedOutput = null; + + nested.RegisterHandler(context => context.SetOutput("nested")); + + parent.RegisterHandler(context => + { + using var nestedSubscription = nested.Handle(Unit.Default).Subscribe(output => nestedOutput = output); + nestedHandledBeforeParentReturned = nestedOutput == "nested"; + + context.SetOutput(Unit.Default); + return Task.CompletedTask; + }); + + await parent.Handle(Unit.Default); + + await Assert.That(nestedHandledBeforeParentReturned).IsTrue(); + } + + /// + /// Tests that observable handlers release the current scheduler before invoking user code. + /// + /// A representing the asynchronous operation. + [Test] + public async Task ObservableHandlersShouldNotBlockNestedInteractionsBeforeReturningObservable() + { + var parent = new Interaction(); + var nested = new Interaction(); + var nestedHandledBeforeParentReturned = false; + string? nestedOutput = null; + + nested.RegisterHandler(context => context.SetOutput("nested")); + + parent.RegisterHandler(context => + { + using var nestedSubscription = nested.Handle(Unit.Default).Subscribe(output => nestedOutput = output); + nestedHandledBeforeParentReturned = nestedOutput == "nested"; + + context.SetOutput(Unit.Default); + return Observables.Unit; + }); + + await parent.Handle(Unit.Default); + + await Assert.That(nestedHandledBeforeParentReturned).IsTrue(); + } + /// /// Tests that handlers can opt not to handle the interaction. /// @@ -174,21 +241,21 @@ public async Task HandlersCanOptNotToHandleTheInteraction() using (handler1C) using (Assert.Multiple()) { - await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("C"); - await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("C"); + await Assert.That(await interaction.Handle(false)).IsEqualTo("C"); + await Assert.That(await interaction.Handle(true)).IsEqualTo("C"); } using (Assert.Multiple()) { - await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("A"); - await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("B"); + await Assert.That(await interaction.Handle(false)).IsEqualTo("A"); + await Assert.That(await interaction.Handle(true)).IsEqualTo("B"); } } using (Assert.Multiple()) { - await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("A"); - await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("A"); + await Assert.That(await interaction.Handle(false)).IsEqualTo("A"); + await Assert.That(await interaction.Handle(true)).IsEqualTo("A"); } } } @@ -207,7 +274,7 @@ public async Task HandlersReturningObservablesCanReturnAnyKindOfObservable() .Return(42) .Do(_ => x.SetOutput("result"))); - var result = interaction.Handle(Unit.Default).FirstAsync().Wait(); + var result = await interaction.Handle(Unit.Default); await Assert.That(result).IsEqualTo("result"); } @@ -222,19 +289,19 @@ public async Task NestedHandlersAreExecutedInReverseOrderOfSubscription() using (interaction.RegisterHandler(static x => x.SetOutput("A"))) { - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("A"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("A"); using (interaction.RegisterHandler(static x => x.SetOutput("B"))) { - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("B"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("B"); using (interaction.RegisterHandler(static x => x.SetOutput("C"))) { - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("C"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("C"); } - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("B"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("B"); } - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("A"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("A"); } } @@ -261,21 +328,21 @@ public void RegisterNullHandlerShouldCauseException() public async Task UnhandledInteractionsShouldCauseException() { var interaction = new Interaction(); - var ex = Assert.Throws>(() => - interaction.Handle("foo").FirstAsync().Wait()); + var ex = await Assert.ThrowsAsync>(() => + interaction.Handle("foo").ToTask()); using (Assert.Multiple()) { - await Assert.That(ex.Interaction).IsSameReferenceAs(interaction); + await Assert.That(ex!.Interaction).IsSameReferenceAs(interaction); await Assert.That(ex.Input).IsEqualTo("foo"); } interaction.RegisterHandler(_ => { }); interaction.RegisterHandler(_ => { }); - ex = Assert.Throws>(() => - interaction.Handle("bar").FirstAsync().Wait()); + ex = await Assert.ThrowsAsync>(() => + interaction.Handle("bar").ToTask()); using (Assert.Multiple()) { - await Assert.That(ex.Interaction).IsSameReferenceAs(interaction); + await Assert.That(ex!.Interaction).IsSameReferenceAs(interaction); await Assert.That(ex.Input).IsEqualTo("bar"); } } From 926fec1cf590552011924686dd119e16cb94af26 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Mon, 4 May 2026 15:14:27 +0100 Subject: [PATCH 2/2] Fix interaction scheduler test on Release runners --- .../ReactiveUI.Tests/InteractionsTest.cs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/tests/ReactiveUI.Tests/InteractionsTest.cs b/src/tests/ReactiveUI.Tests/InteractionsTest.cs index 8446411e39..082773fe66 100644 --- a/src/tests/ReactiveUI.Tests/InteractionsTest.cs +++ b/src/tests/ReactiveUI.Tests/InteractionsTest.cs @@ -65,21 +65,32 @@ public async Task HandledInteractionsShouldNotCauseException() /// /// A representing the asynchronous operation. [Test] - [TestExecutor] public async Task HandlersAreExecutedOnHandlerScheduler() { - var scheduler = TestContext.Current!.GetScheduler(); + var schedulerThreadId = -1; + using var scheduler = new EventLoopScheduler( + threadStart => + { + var thread = new Thread(threadStart) { IsBackground = true }; + schedulerThreadId = thread.ManagedThreadId; + return thread; + }); var interaction = new Interaction(scheduler); + var handlerThreadId = -1; - using (interaction.RegisterHandler(x => x.SetOutput("done"))) + using (interaction.RegisterHandler(x => { - var handled = false; - interaction - .Handle(Unit.Default) - .Subscribe(_ => handled = true); + handlerThreadId = Environment.CurrentManagedThreadId; + x.SetOutput("done"); + })) + { + var result = await interaction.Handle(Unit.Default).ToTask().WaitAsync(TimeSpan.FromSeconds(5)); - // With ImmediateScheduler, handlers execute immediately - await Assert.That(handled).IsTrue(); + using (Assert.Multiple()) + { + await Assert.That(result).IsEqualTo("done"); + await Assert.That(handlerThreadId).IsEqualTo(schedulerThreadId); + } } }