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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions src/ReactiveUI.Shared/Interactions/Interaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,17 @@ public IDisposable RegisterHandler(Func<IInteractionContext<TInput, TOutput>, Ta

return RegisterHandlerCore(ContentHandler);

// Yield before invoking the async handler so it is not run inside the current scheduler
// trampoline (see #4351).
IObservable<RxVoid> ContentHandler(IInteractionContext<TInput, TOutput> interaction) =>
new TaskUnitObservable(InvokeAsync(interaction, handler));

static async Task InvokeAsync(
IInteractionContext<TInput, TOutput> interaction,
Func<IInteractionContext<TInput, TOutput>, Task> asyncHandler)
{
await YieldToCurrentContext().ConfigureAwait(false);
// Yield so the handler is not invoked inside the current scheduler trampoline (see #4351). Task.Yield
// resumes on the captured SynchronizationContext, so a handler registered from a UI thread runs back on
// that context instead of a thread-pool thread (see #4393); ConfigureAwait(false) here would discard it.
await Task.Yield();
await asyncHandler(interaction).ConfigureAwait(false);
}
}
Expand Down Expand Up @@ -169,10 +170,6 @@ protected Func<IInteractionContext<TInput, TOutput>, IObservable<RxVoid>>[] GetH
protected virtual IOutputContext<TInput, TOutput> GenerateContext(TInput input) =>
new InteractionContext<TInput, TOutput>(input);

/// <summary>Yields once so asynchronous handlers are not invoked inside the current scheduler trampoline.</summary>
/// <returns>A task that completes after the current context has yielded.</returns>
private static async Task YieldToCurrentContext() => await Task.Yield();

/// <summary>Registers a normalized interaction handler that produces a <see cref="RxVoid"/> stream.</summary>
/// <param name="contentHandler">The normalized handler.</param>
/// <returns>A disposable which unregisters the handler.</returns>
Expand Down
114 changes: 114 additions & 0 deletions src/tests/ReactiveUI.Tests/InteractionsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// The .NET Foundation 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.Tests.Utilities.Schedulers;
using TUnit.Core.Executors;

Expand Down Expand Up @@ -270,4 +271,117 @@ public async Task UnhandledInteractionsShouldCauseException()
await Assert.That(ex.Input).IsEqualTo("bar");
}
}

/// <summary>
/// A task-based handler registered while a <see cref="SynchronizationContext"/> is installed resumes on that
/// captured context rather than on a thread-pool thread, so UI handlers run on the UI thread (see issue #4393).
/// </summary>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
[Test]
public async Task TaskHandlerResumesOnCapturedSynchronizationContext()
{
using var uiContext = new SingleThreadedSynchronizationContext();
var observedContext = new TaskCompletionSource<SynchronizationContext?>(TaskCreationOptions.RunContinuationsAsynchronously);
var completed = new TaskCompletionSource<RxVoid>(TaskCreationOptions.RunContinuationsAsynchronously);

uiContext.Post(
static state =>
{
var (observed, done) = ((TaskCompletionSource<SynchronizationContext?>, TaskCompletionSource<RxVoid>))state!;
var interaction = new Interaction<RxVoid, RxVoid>();
_ = interaction.RegisterHandler(async context =>
{
_ = observed.TrySetResult(SynchronizationContext.Current);
context.SetOutput(RxVoid.Default);
await Task.CompletedTask;
});

_ = interaction.Handle(RxVoid.Default).Subscribe(
_ => done.TrySetResult(RxVoid.Default),
done.SetException);
},
(observedContext, completed));

var handlerContext = await observedContext.Task;
_ = await completed.Task;

await Assert.That(handlerContext).IsSameReferenceAs(uiContext);
}

/// <summary>
/// A task-based handler yields before running, so it is not invoked inside the subscription that triggers the
/// interaction (see issue #4351); the handler only runs once the triggering call has unwound.
/// </summary>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
[Test]
public async Task TaskHandlerDoesNotRunInsideTriggeringSubscription()
{
const string afterSubscribeMarker = "after-subscribe";
const string handlerMarker = "handler";
const int expectedStepCount = 2;

using var uiContext = new SingleThreadedSynchronizationContext();
var order = new List<string>();
var completed = new TaskCompletionSource<IReadOnlyList<string>>(TaskCreationOptions.RunContinuationsAsynchronously);

uiContext.Post(
static state =>
{
var (steps, done) = ((List<string>, TaskCompletionSource<IReadOnlyList<string>>))state!;
var interaction = new Interaction<RxVoid, string>();
_ = interaction.RegisterHandler(async context =>
{
steps.Add(handlerMarker);
context.SetOutput(ResultOutput);
await Task.CompletedTask;
});

_ = interaction.Handle(RxVoid.Default).Subscribe(
_ => done.TrySetResult(steps),
done.SetException);

// Recorded before the yielded handler continuation is pumped, so it must precede the handler marker.
steps.Add(afterSubscribeMarker);
},
(order, completed));

var sequence = await completed.Task;

using (Assert.Multiple())
{
await Assert.That(sequence).Count().IsEqualTo(expectedStepCount);
await Assert.That(sequence[0]).IsEqualTo(afterSubscribeMarker);
await Assert.That(sequence[1]).IsEqualTo(handlerMarker);
}
}

/// <summary>A minimal single-threaded <see cref="SynchronizationContext"/> backed by one pumped worker thread.</summary>
private sealed class SingleThreadedSynchronizationContext : SynchronizationContext, IDisposable
{
/// <summary>The queue of work items pumped on the worker thread in order.</summary>
private readonly BlockingCollection<Action> _queue = new();

/// <summary>Initializes a new instance of the <see cref="SingleThreadedSynchronizationContext"/> class.</summary>
public SingleThreadedSynchronizationContext()
{
var thread = new Thread(Run) { IsBackground = true, Name = "interaction-ui" };
thread.Start();
}

/// <inheritdoc/>
public override void Post(SendOrPostCallback d, object? state) => _queue.Add(() => d(state));

/// <inheritdoc/>
public void Dispose() => _queue.CompleteAdding();

/// <summary>Pumps queued work on the worker thread with this context installed as current.</summary>
private void Run()
{
SynchronizationContext.SetSynchronizationContext(this);
foreach (var work in _queue.GetConsumingEnumerable())
{
work();
}
}
}
}
Loading