diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs index 69404b29..6de04a0d 100644 --- a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs @@ -5,6 +5,7 @@ using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Runtime.CompilerServices; +using Microsoft.Maui; using Microsoft.Maui.Dispatching; namespace ReactiveUI.Primitives.Reactive.Concurrency; @@ -15,6 +16,13 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; [System.Diagnostics.DebuggerDisplay("MauiDispatcherSequencer: Dispatcher = {Dispatcher}")] public sealed class MauiDispatcherSequencer : LocalScheduler { + /// The shared main-thread scheduler, set once the application's dispatcher is available. + private static MauiDispatcherSequencer? _main; + + /// The scheduler for the calling thread's dispatcher, cached per thread. + [ThreadStatic] + private static MauiDispatcherSequencer? _current; + /// Queues work and coalesces dispatcher drains. private CoalescingDispatchState _dispatch; @@ -27,6 +35,23 @@ public MauiDispatcherSequencer(IDispatcher dispatcher) _dispatch = new(RunDrain, DefaultScheduler.Instance); } + /// Gets the shared scheduler for the MAUI main (UI) thread. + /// No application exists yet and the calling thread has no dispatcher. + /// + /// Bound once to the running application's dispatcher, from any thread. Until an application exists nothing is + /// cached, and the calling thread's existing dispatcher is used instead. Never falls back to the thread pool. + /// + public static MauiDispatcherSequencer Main => + Volatile.Read(ref _main) ?? BindMain(ref _main, ResolveApplicationDispatcher(IPlatformApplication.Current)) ?? Current; + + /// Gets the scheduler for the calling thread's dispatcher. + /// The calling thread has no dispatcher. + /// + /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher: a thread + /// that has none cannot run the scheduled work. + /// + public static MauiDispatcherSequencer Current => _current ??= new(ResolveCurrentDispatcher()); + /// Gets the dispatcher used to marshal work to the UI thread. public IDispatcher Dispatcher { get; } @@ -42,6 +67,35 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) => _dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action); + /// Caches the shared main-thread scheduler for the application's dispatcher, keeping the first one bound. + /// The field that holds the shared scheduler. + /// The application's dispatcher, or when no application exists. + /// The shared scheduler, or when is . + internal static MauiDispatcherSequencer? BindMain(ref MauiDispatcherSequencer? slot, IDispatcher? applicationDispatcher) + { + if (applicationDispatcher is null) + { + return null; + } + + MauiDispatcherSequencer created = new(applicationDispatcher); + return Interlocked.CompareExchange(ref slot, created, null) ?? created; + } + + /// Returns the application's dispatcher, which MAUI resolves on the UI thread while it starts the application. + /// The running platform application, or before it exists. + /// The application's dispatcher, or when no application or dispatcher exists yet. + internal static IDispatcher? ResolveApplicationDispatcher(IPlatformApplication? application) => + application?.Services?.GetService(typeof(IDispatcher)) as IDispatcher; + + /// Returns the calling thread's existing dispatcher without creating one. + /// The calling thread's dispatcher. + /// The calling thread has no dispatcher. + internal static IDispatcher ResolveCurrentDispatcher() => + Microsoft.Maui.Dispatching.Dispatcher.GetForCurrentThread() + ?? throw new InvalidOperationException( + "The calling thread has no MAUI dispatcher. Use MauiDispatcherSequencer.Main or Current from a UI thread, or after the application is created."); + /// Schedules delayed work through the dispatcher. /// The callback to run. /// The requested delay. diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt index a730f18c..6b0eb812 100644 --- a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt @@ -6,6 +6,8 @@ public sealed class MauiDispatcherSequencer : System.Reactive.Concurrency.LocalS { public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { } public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Current { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt index a730f18c..6b0eb812 100644 --- a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt @@ -6,6 +6,8 @@ public sealed class MauiDispatcherSequencer : System.Reactive.Concurrency.LocalS { public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { } public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Current { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs index ad7aa548..0bb47552 100644 --- a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Runtime.CompilerServices; +using Microsoft.Maui; using Microsoft.Maui.Dispatching; using ReactiveUI.Primitives.Advanced; @@ -17,6 +18,13 @@ namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class MauiDispatcherSequencer : ISequencer { + /// The shared main-thread sequencer, set once the application's dispatcher is available. + private static MauiDispatcherSequencer? _main; + + /// The sequencer for the calling thread's dispatcher, cached per thread. + [ThreadStatic] + private static MauiDispatcherSequencer? _current; + /// Coalescing dispatch engine. private DispatchSequencerState _state; @@ -29,6 +37,23 @@ public MauiDispatcherSequencer(IDispatcher dispatcher) _state = new(this, Post, RunDrain, ScheduleDelayed); } + /// Gets the shared sequencer for the MAUI main (UI) thread. + /// No application exists yet and the calling thread has no dispatcher. + /// + /// Bound once to the running application's dispatcher, from any thread. Until an application exists nothing is + /// cached, and the calling thread's existing dispatcher is used instead. Never falls back to the thread pool. + /// + public static MauiDispatcherSequencer Main => + Volatile.Read(ref _main) ?? BindMain(ref _main, ResolveApplicationDispatcher(IPlatformApplication.Current)) ?? Current; + + /// Gets the sequencer for the calling thread's dispatcher. + /// The calling thread has no dispatcher. + /// + /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher: a thread + /// that has none cannot run the scheduled work. + /// + public static MauiDispatcherSequencer Current => _current ??= new(ResolveCurrentDispatcher()); + /// Gets the dispatcher used to marshal work to the UI thread. public IDispatcher Dispatcher { get; } @@ -51,6 +76,35 @@ public MauiDispatcherSequencer(IDispatcher dispatcher) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp); + /// Caches the shared main-thread sequencer for the application's dispatcher, keeping the first one bound. + /// The field that holds the shared sequencer. + /// The application's dispatcher, or when no application exists. + /// The shared sequencer, or when is . + internal static MauiDispatcherSequencer? BindMain(ref MauiDispatcherSequencer? slot, IDispatcher? applicationDispatcher) + { + if (applicationDispatcher is null) + { + return null; + } + + MauiDispatcherSequencer created = new(applicationDispatcher); + return Interlocked.CompareExchange(ref slot, created, null) ?? created; + } + + /// Returns the application's dispatcher, which MAUI resolves on the UI thread while it starts the application. + /// The running platform application, or before it exists. + /// The application's dispatcher, or when no application or dispatcher exists yet. + internal static IDispatcher? ResolveApplicationDispatcher(IPlatformApplication? application) => + application?.Services?.GetService(typeof(IDispatcher)) as IDispatcher; + + /// Returns the calling thread's existing dispatcher without creating one. + /// The calling thread's dispatcher. + /// The calling thread has no dispatcher. + internal static IDispatcher ResolveCurrentDispatcher() => + Microsoft.Maui.Dispatching.Dispatcher.GetForCurrentThread() + ?? throw new InvalidOperationException( + "The calling thread has no MAUI dispatcher. Use MauiDispatcherSequencer.Main or Current from a UI thread, or after the application is created."); + /// Marshals the cached drain callback through the dispatcher. /// The drain callback. /// when the dispatcher accepted the work. diff --git a/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt index 9c8f1300..4194572c 100644 --- a/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt @@ -1,3 +1,4 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Maui.Tests")] namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -5,6 +6,8 @@ public sealed class MauiDispatcherSequencer : ReactiveUI.Primitives.Concurrency. { public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { } public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Current { get; } + public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt index 9c8f1300..4194572c 100644 --- a/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt @@ -1,3 +1,4 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Maui.Tests")] namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] @@ -5,6 +6,8 @@ public sealed class MauiDispatcherSequencer : ReactiveUI.Primitives.Concurrency. { public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { } public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Current { get; } + public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj b/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj index a374b0d0..e32f362f 100644 --- a/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj +++ b/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs index f4ce366d..54266e48 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs @@ -13,6 +13,9 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; [System.Diagnostics.DebuggerDisplay("ControlSequencer: Control = {Control}")] public sealed class ControlSequencer : LocalScheduler { + /// The shared main-thread scheduler, set once a UI thread first reads it. + private static ControlSequencer? _main; + /// Optional callback for posting ready work. private readonly Func? _post; @@ -40,6 +43,17 @@ internal ControlSequencer(Control control, Func? post, IScheduler? Control.HandleCreated += OnHandleCreated; } + /// Gets the shared scheduler for the Windows Forms main (UI) thread. + /// Main is not bound yet and the calling thread is not an STA thread. + /// + /// The first read binds Main to a hidden control whose handle belongs to the calling thread. Windows Forms UI + /// threads are always STA and thread-pool threads never are, so a read from any other thread throws and nothing is + /// cached. Read it first on the thread that runs the message loop, such as in Main before + /// . + /// + public static ControlSequencer Main => + Volatile.Read(ref _main) ?? BindMain(ref _main, Thread.CurrentThread.GetApartmentState()); + /// Gets the control used to marshal work to the UI thread. public Control Control { get; } @@ -57,6 +71,32 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) => _dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action); + /// Caches the shared main-thread scheduler for a hidden control on the calling thread, keeping the first one bound. + /// The field that holds the shared scheduler. + /// The calling thread's apartment state. + /// The shared scheduler. + /// is not . + internal static ControlSequencer BindMain(ref ControlSequencer? slot, ApartmentState apartment) + { + if (apartment != ApartmentState.STA) + { + throw new InvalidOperationException( + "The calling thread is not a Windows Forms UI thread. Read ControlSequencer.Main first on the STA thread that runs the message loop."); + } + + Control control = new(); + _ = control.Handle; + ControlSequencer created = new(control); + var bound = Interlocked.CompareExchange(ref slot, created, null); + if (bound is null) + { + return created; + } + + control.Dispose(); + return bound; + } + /// 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. diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt index 12a4575b..81367b4b 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt index 12a4575b..81367b4b 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt index 13610393..b1a1cc09 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt index 13610393..b1a1cc09 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt index 13610393..b1a1cc09 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt index 13610393..b1a1cc09 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt index 12a4575b..81367b4b 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt index 12a4575b..81367b4b 100644 --- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs index 0187d28f..3701f98a 100644 --- a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs +++ b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs @@ -15,6 +15,9 @@ namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class ControlSequencer : ISequencer { + /// The shared main-thread sequencer, set once a UI thread first reads it. + private static ControlSequencer? _main; + /// Optional callback for posting ready work. private readonly Func? _post; @@ -42,6 +45,17 @@ internal ControlSequencer(Control control, Func? post, ActionGets the shared sequencer for the Windows Forms main (UI) thread. + /// Main is not bound yet and the calling thread is not an STA thread. + /// + /// The first read binds Main to a hidden control whose handle belongs to the calling thread. Windows Forms UI + /// threads are always STA and thread-pool threads never are, so a read from any other thread throws and nothing is + /// cached. Read it first on the thread that runs the message loop, such as in Main before + /// . + /// + public static ControlSequencer Main => + Volatile.Read(ref _main) ?? BindMain(ref _main, Thread.CurrentThread.GetApartmentState()); + /// Gets the control used to marshal work to the UI thread. public Control Control { get; } @@ -63,6 +77,32 @@ internal ControlSequencer(Control control, Func? post, Action _state.Schedule(item, dueTimestamp); + /// Caches the shared main-thread sequencer for a hidden control on the calling thread, keeping the first one bound. + /// The field that holds the shared sequencer. + /// The calling thread's apartment state. + /// The shared sequencer. + /// is not . + internal static ControlSequencer BindMain(ref ControlSequencer? slot, ApartmentState apartment) + { + if (apartment != ApartmentState.STA) + { + throw new InvalidOperationException( + "The calling thread is not a Windows Forms UI thread. Read ControlSequencer.Main first on the STA thread that runs the message loop."); + } + + Control control = new(); + _ = control.Handle; + ControlSequencer created = new(control); + var bound = Interlocked.CompareExchange(ref slot, created, null); + if (bound is null) + { + return created; + } + + control.Dispose(); + return bound; + } + /// 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. diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt index 377808e5..6aefebe8 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt index 377808e5..6aefebe8 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt index aba67e76..808f3f1b 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt index aba67e76..808f3f1b 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt index aba67e76..808f3f1b 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt index aba67e76..808f3f1b 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt @@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt index 377808e5..6aefebe8 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt index 377808e5..6aefebe8 100644 --- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen { public ControlSequencer(System.Windows.Forms.Control control) { } public System.Windows.Forms.Control Control { get; } + public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; } public System.DateTimeOffset Now { get; } public long Timestamp { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs index bba4c55c..820fce25 100644 --- a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs @@ -15,6 +15,13 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; [System.Diagnostics.DebuggerDisplay("DispatcherQueueSequencer: DispatcherQueue = {DispatcherQueue}, Priority = {Priority}")] public sealed class DispatcherQueueSequencer : LocalScheduler { + /// The shared main-thread scheduler, set once a UI thread first reads it. + private static DispatcherQueueSequencer? _main; + + /// The scheduler for the calling thread's dispatcher queue, cached per thread. + [ThreadStatic] + private static DispatcherQueueSequencer? _current; + /// Optional callback for enqueueing native drain delegates. private readonly Func? _tryEnqueue; @@ -66,6 +73,25 @@ internal DispatcherQueueSequencer( _dispatch = new(RunDrain, DefaultScheduler.Instance); } + /// Gets the shared scheduler for the WinUI main (UI) thread. + /// Main is not bound yet and the calling thread has no dispatcher queue. + /// + /// WinUI has no dispatcher queue that any thread can reach, so the first read from a UI thread binds Main to that + /// thread's dispatcher queue. After that, every thread gets the same scheduler. A read from a thread without a + /// dispatcher queue before then throws, and nothing is cached, so a later read from the UI thread still binds. + /// + public static DispatcherQueueSequencer Main => + Volatile.Read(ref _main) ?? BindMain(ref _main, DispatcherQueue.GetForCurrentThread()) ?? throw NoDispatcherQueue(); + + /// Gets the scheduler for the calling thread's dispatcher queue. + /// The calling thread has no dispatcher queue. + /// + /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher queue: a + /// thread that has none cannot run the scheduled work. + /// + public static DispatcherQueueSequencer Current => + _current ??= new(DispatcherQueue.GetForCurrentThread() ?? throw NoDispatcherQueue()); + /// Gets the dispatcher queue used to marshal work to the UI thread. public DispatcherQueue DispatcherQueue { get; } @@ -86,6 +112,26 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) => _dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action); + /// Caches the shared main-thread scheduler for a dispatcher queue, keeping the first one bound. + /// The field that holds the shared scheduler. + /// The calling thread's dispatcher queue, or when it has none. + /// The shared scheduler, or when is . + internal static DispatcherQueueSequencer? BindMain(ref DispatcherQueueSequencer? slot, DispatcherQueue? dispatcherQueue) + { + if (dispatcherQueue is null) + { + return null; + } + + DispatcherQueueSequencer created = new(dispatcherQueue); + return Interlocked.CompareExchange(ref slot, created, null) ?? created; + } + + /// Creates the error for a thread that has no dispatcher queue. + /// The exception to throw. + internal static InvalidOperationException NoDispatcherQueue() => + new("The calling thread has no WinUI dispatcher queue. Use DispatcherQueueSequencer.Main or Current from a UI thread."); + /// Enqueues the drain callback on the dispatcher queue. /// The drain callback. /// Always . 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 30be1c6e..6bcc1ebd 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 @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } 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 30be1c6e..6bcc1ebd 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 @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } 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 30be1c6e..6bcc1ebd 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 @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } 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 30be1c6e..6bcc1ebd 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 @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public override System.IDisposable Schedule(TState state, System.Func action) { } diff --git a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs index bfa1ac2d..dc69bd82 100644 --- a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs +++ b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs @@ -14,6 +14,13 @@ namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DispatcherQueueSequencer : ISequencer { + /// The shared main-thread sequencer, set once a UI thread first reads it. + private static DispatcherQueueSequencer? _main; + + /// The sequencer for the calling thread's dispatcher queue, cached per thread. + [ThreadStatic] + private static DispatcherQueueSequencer? _current; + /// Optional callback for enqueueing native drain delegates. private readonly Func? _tryEnqueue; @@ -61,6 +68,25 @@ internal DispatcherQueueSequencer( _state = new(this, Post, RunDrain, scheduleDelayed); } + /// Gets the shared sequencer for the WinUI main (UI) thread. + /// Main is not bound yet and the calling thread has no dispatcher queue. + /// + /// WinUI has no dispatcher queue that any thread can reach, so the first read from a UI thread binds Main to that + /// thread's dispatcher queue. After that, every thread gets the same sequencer. A read from a thread without a + /// dispatcher queue before then throws, and nothing is cached, so a later read from the UI thread still binds. + /// + public static DispatcherQueueSequencer Main => + Volatile.Read(ref _main) ?? BindMain(ref _main, DispatcherQueue.GetForCurrentThread()) ?? throw NoDispatcherQueue(); + + /// Gets the sequencer for the calling thread's dispatcher queue. + /// The calling thread has no dispatcher queue. + /// + /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher queue: a + /// thread that has none cannot run the scheduled work. + /// + public static DispatcherQueueSequencer Current => + _current ??= new(DispatcherQueue.GetForCurrentThread() ?? throw NoDispatcherQueue()); + /// Gets the dispatcher queue used to marshal work to the UI thread. public DispatcherQueue DispatcherQueue { get; } @@ -85,6 +111,26 @@ internal DispatcherQueueSequencer( [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp); + /// Caches the shared main-thread sequencer for a dispatcher queue, keeping the first one bound. + /// The field that holds the shared sequencer. + /// The calling thread's dispatcher queue, or when it has none. + /// The shared sequencer, or when is . + internal static DispatcherQueueSequencer? BindMain(ref DispatcherQueueSequencer? slot, DispatcherQueue? dispatcherQueue) + { + if (dispatcherQueue is null) + { + return null; + } + + DispatcherQueueSequencer created = new(dispatcherQueue); + return Interlocked.CompareExchange(ref slot, created, null) ?? created; + } + + /// Creates the error for a thread that has no dispatcher queue. + /// The exception to throw. + internal static InvalidOperationException NoDispatcherQueue() => + new("The calling thread has no WinUI dispatcher queue. Use DispatcherQueueSequencer.Main or Current from a UI thread."); + /// Marshals the cached drain callback through the dispatcher queue. /// The drain callback. /// when the drain was enqueued. diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt index f6b8bb0b..17383cf1 100644 --- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; } public System.DateTimeOffset Now { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt index f6b8bb0b..17383cf1 100644 --- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; } public System.DateTimeOffset Now { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt index f6b8bb0b..17383cf1 100644 --- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; } public System.DateTimeOffset Now { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt index f6b8bb0b..17383cf1 100644 --- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt @@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { } public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { } public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; } public System.DateTimeOffset Now { get; } public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; } public long Timestamp { get; } diff --git a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs index 68c8cc03..2cf0c966 100644 --- a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs @@ -4,6 +4,7 @@ using System.Reactive.Concurrency; using System.Runtime.CompilerServices; +using Microsoft.Maui; using Microsoft.Maui.Dispatching; using ReactiveUI.Primitives.Reactive.Concurrency; @@ -15,6 +16,10 @@ public sealed class MauiDispatcherSequencerTests /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; + /// Installs a dispatcher provider that returns the dispatcher each test thread registers. + [Before(Class)] + public static void InstallThreadDispatcherProvider() => _ = DispatcherProvider.SetCurrent(new ThreadDispatcherProvider()); + /// Verifies the constructor rejects a null dispatcher. /// A representing the asynchronous operation. [Test] @@ -101,6 +106,161 @@ public async Task ImmediateBurstExecutesInOrder() await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching); } + /// A thread without a dispatcher gets an error rather than a sequencer that could never run work. + /// A representing the asynchronous operation. + [Test] + public async Task CurrentThrowsWhenTheThreadHasNoDispatcher() => + await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Current)) + .ThrowsExactly(); + + /// Each thread gets its own cached sequencer bound to that thread's dispatcher. + /// A representing the asynchronous operation. + [Test] + public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsDispatcher() + { + FakeDispatcher firstDispatcher = new(); + FakeDispatcher secondDispatcher = new(); + + var first = await RunOnNewThread(firstDispatcher, CaptureCurrent); + var second = await RunOnNewThread(secondDispatcher, CaptureCurrent); + + await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer); + await Assert.That(first.Sequencer.Dispatcher).IsSameReferenceAs(firstDispatcher); + await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer); + await Assert.That(second.Sequencer.Dispatcher).IsSameReferenceAs(secondDispatcher); + } + + /// Before an application exists, Main uses the calling thread's dispatcher and never the thread pool. + /// A representing the asynchronous operation. + [Test] + public async Task MainFallsBackToCurrentBeforeAnApplicationExists() + { + if (IPlatformApplication.Current is not null) + { + return; + } + + FakeDispatcher dispatcher = new(); + var captured = await RunOnNewThread( + dispatcher, + static () => (MauiDispatcherSequencer.Main, MauiDispatcherSequencer.Current)); + + await Assert.That(captured.Main).IsSameReferenceAs(captured.Current); + await Assert.That(captured.Main.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Main)) + .ThrowsExactly(); + } + + /// The application's dispatcher comes from the running application's services. + /// A representing the asynchronous operation. + [Test] + public async Task ResolveApplicationDispatcherReadsTheApplicationServices() + { + FakeDispatcher dispatcher = new(); + + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(null)).IsNull(); + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(null))).IsNull(); + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(null)))) + .IsNull(); + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(dispatcher)))) + .IsSameReferenceAs(dispatcher); + } + + /// Without an application dispatcher there is nothing to bind, and nothing is cached. + /// A representing the asynchronous operation. + [Test] + public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() + { + MauiDispatcherSequencer? slot = null; + await Assert.That(MauiDispatcherSequencer.BindMain(ref slot, null)).IsNull(); + await Assert.That(slot).IsNull(); + } + + /// The first application dispatcher bound stays bound. + /// A representing the asynchronous operation. + [Test] + public async Task BindMainKeepsTheFirstBinding() + { + FakeDispatcher first = new(); + FakeDispatcher second = new(); + MauiDispatcherSequencer? slot = null; + + var bound = MauiDispatcherSequencer.BindMain(ref slot, first); + var rebound = MauiDispatcherSequencer.BindMain(ref slot, second); + + await Assert.That(bound).IsNotNull(); + await Assert.That(bound!.Dispatcher).IsSameReferenceAs(first); + await Assert.That(slot).IsSameReferenceAs(bound); + await Assert.That(rebound).IsSameReferenceAs(bound); + } + + /// Reads twice on the calling thread. + /// Both reads. + private static (MauiDispatcherSequencer Sequencer, MauiDispatcherSequencer Repeat) CaptureCurrent() => + (MauiDispatcherSequencer.Current, MauiDispatcherSequencer.Current); + + /// Runs a function on a fresh thread that reports the given dispatcher as its own. + /// The result type. + /// The dispatcher the thread reports, or for none. + /// The function to run. + /// The function's result. + private static Task RunOnNewThread(IDispatcher? dispatcher, Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + Thread thread = new(() => + { + ThreadDispatcherProvider.ForThread = dispatcher; + try + { + completion.SetResult(func()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }); + thread.Start(); + return completion.Task; + } + + /// Dispatcher provider that reports the dispatcher registered by the calling thread. + private sealed class ThreadDispatcherProvider : IDispatcherProvider + { + /// The dispatcher registered by the calling thread. + [ThreadStatic] + private static IDispatcher? _forThread; + + /// Gets or sets the dispatcher registered by the calling thread. + public static IDispatcher? ForThread + { + get => _forThread; + set => _forThread = value; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDispatcher? GetForCurrentThread() => _forThread; + } + + /// Platform application that exposes the given services. + /// The application services, or before MAUI sets them. + private sealed class FakePlatformApplication(IServiceProvider? services) : IPlatformApplication + { + /// + public IServiceProvider Services => services!; + + /// + public IApplication Application => null!; + } + + /// Service provider that resolves only . + /// The dispatcher to return, or for none. + private sealed class DispatcherServices(IDispatcher? dispatcher) : IServiceProvider + { + /// + public object? GetService(Type serviceType) => serviceType == typeof(IDispatcher) ? dispatcher : null; + } + /// Fake MAUI dispatcher that runs marshalled work synchronously and records how it was dispatched. private sealed class FakeDispatcher : IDispatcher { diff --git a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs index bea3957c..22f43b1e 100644 --- a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Runtime.CompilerServices; +using Microsoft.Maui; using Microsoft.Maui.Dispatching; using ReactiveUI.Primitives.Concurrency; @@ -14,6 +15,10 @@ public sealed class MauiDispatcherSequencerTests /// The values an immediate burst produces, in the FIFO order asserted. private static readonly int[] ExpectedBurst = [1, 2, 3]; + /// Installs a dispatcher provider that returns the dispatcher each test thread registers. + [Before(Class)] + public static void InstallThreadDispatcherProvider() => _ = DispatcherProvider.SetCurrent(new ThreadDispatcherProvider()); + /// Verifies the constructor rejects a null dispatcher. /// A representing the asynchronous operation. [Test] @@ -115,6 +120,161 @@ public async Task ClockPropertiesReportTheSharedDispatchClock() await Assert.That(sequencer.Timestamp).IsGreaterThanOrEqualTo(before); } + /// A thread without a dispatcher gets an error rather than a sequencer that could never run work. + /// A representing the asynchronous operation. + [Test] + public async Task CurrentThrowsWhenTheThreadHasNoDispatcher() => + await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Current)) + .ThrowsExactly(); + + /// Each thread gets its own cached sequencer bound to that thread's dispatcher. + /// A representing the asynchronous operation. + [Test] + public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsDispatcher() + { + FakeDispatcher firstDispatcher = new(); + FakeDispatcher secondDispatcher = new(); + + var first = await RunOnNewThread(firstDispatcher, CaptureCurrent); + var second = await RunOnNewThread(secondDispatcher, CaptureCurrent); + + await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer); + await Assert.That(first.Sequencer.Dispatcher).IsSameReferenceAs(firstDispatcher); + await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer); + await Assert.That(second.Sequencer.Dispatcher).IsSameReferenceAs(secondDispatcher); + } + + /// Before an application exists, Main uses the calling thread's dispatcher and never the thread pool. + /// A representing the asynchronous operation. + [Test] + public async Task MainFallsBackToCurrentBeforeAnApplicationExists() + { + if (IPlatformApplication.Current is not null) + { + return; + } + + FakeDispatcher dispatcher = new(); + var captured = await RunOnNewThread( + dispatcher, + static () => (MauiDispatcherSequencer.Main, MauiDispatcherSequencer.Current)); + + await Assert.That(captured.Main).IsSameReferenceAs(captured.Current); + await Assert.That(captured.Main.Dispatcher).IsSameReferenceAs(dispatcher); + await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Main)) + .ThrowsExactly(); + } + + /// The application's dispatcher comes from the running application's services. + /// A representing the asynchronous operation. + [Test] + public async Task ResolveApplicationDispatcherReadsTheApplicationServices() + { + FakeDispatcher dispatcher = new(); + + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(null)).IsNull(); + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(null))).IsNull(); + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(null)))) + .IsNull(); + await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(dispatcher)))) + .IsSameReferenceAs(dispatcher); + } + + /// Without an application dispatcher there is nothing to bind, and nothing is cached. + /// A representing the asynchronous operation. + [Test] + public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() + { + MauiDispatcherSequencer? slot = null; + await Assert.That(MauiDispatcherSequencer.BindMain(ref slot, null)).IsNull(); + await Assert.That(slot).IsNull(); + } + + /// The first application dispatcher bound stays bound. + /// A representing the asynchronous operation. + [Test] + public async Task BindMainKeepsTheFirstBinding() + { + FakeDispatcher first = new(); + FakeDispatcher second = new(); + MauiDispatcherSequencer? slot = null; + + var bound = MauiDispatcherSequencer.BindMain(ref slot, first); + var rebound = MauiDispatcherSequencer.BindMain(ref slot, second); + + await Assert.That(bound).IsNotNull(); + await Assert.That(bound!.Dispatcher).IsSameReferenceAs(first); + await Assert.That(slot).IsSameReferenceAs(bound); + await Assert.That(rebound).IsSameReferenceAs(bound); + } + + /// Reads twice on the calling thread. + /// Both reads. + private static (MauiDispatcherSequencer Sequencer, MauiDispatcherSequencer Repeat) CaptureCurrent() => + (MauiDispatcherSequencer.Current, MauiDispatcherSequencer.Current); + + /// Runs a function on a fresh thread that reports the given dispatcher as its own. + /// The result type. + /// The dispatcher the thread reports, or for none. + /// The function to run. + /// The function's result. + private static Task RunOnNewThread(IDispatcher? dispatcher, Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + Thread thread = new(() => + { + ThreadDispatcherProvider.ForThread = dispatcher; + try + { + completion.SetResult(func()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }); + thread.Start(); + return completion.Task; + } + + /// Dispatcher provider that reports the dispatcher registered by the calling thread. + private sealed class ThreadDispatcherProvider : IDispatcherProvider + { + /// The dispatcher registered by the calling thread. + [ThreadStatic] + private static IDispatcher? _forThread; + + /// Gets or sets the dispatcher registered by the calling thread. + public static IDispatcher? ForThread + { + get => _forThread; + set => _forThread = value; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IDispatcher? GetForCurrentThread() => _forThread; + } + + /// Platform application that exposes the given services. + /// The application services, or before MAUI sets them. + private sealed class FakePlatformApplication(IServiceProvider? services) : IPlatformApplication + { + /// + public IServiceProvider Services => services!; + + /// + public IApplication Application => null!; + } + + /// Service provider that resolves only . + /// The dispatcher to return, or for none. + private sealed class DispatcherServices(IDispatcher? dispatcher) : IServiceProvider + { + /// + public object? GetService(Type serviceType) => serviceType == typeof(IDispatcher) ? dispatcher : null; + } + /// Work item that invokes a delegate when executed. private sealed class DelegateWorkItem : IWorkItem { diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs index 3f0c0ca9..d9a40198 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs @@ -190,6 +190,82 @@ public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel) handle.Dispose(); } + /// A thread that is not STA gets an error, and nothing is cached. + /// The calling thread's apartment state. + /// The test operation. + [Test] + [Arguments(ApartmentState.MTA)] + [Arguments(ApartmentState.Unknown)] + public async Task BindMainRejectsAThreadThatIsNotStaAndCachesNothing(ApartmentState apartment) + { + ControlSequencer? slot = null; + InvalidOperationException? error = null; + try + { + _ = ControlSequencer.BindMain(ref slot, apartment); + } + catch (InvalidOperationException ex) + { + error = ex; + } + + await Assert.That(error).IsNotNull(); + await Assert.That(slot).IsNull(); + } + + /// On an STA thread, Main owns a hidden control whose handle belongs to that thread, and the first binding stays bound. + /// The test operation. + [Test] + public async Task BindMainOnAnStaThreadCreatesTheHandleOnThatThread() + { + var captured = await RunOnStaThread(static () => + { + ControlSequencer? slot = null; + var bound = ControlSequencer.BindMain(ref slot, ApartmentState.STA); + var rebound = ControlSequencer.BindMain(ref slot, ApartmentState.STA); + return (Bound: bound, Rebound: rebound, Slot: slot, bound.Control.IsHandleCreated, bound.Control.InvokeRequired); + }); + + await Assert.That(captured.IsHandleCreated).IsTrue(); + await Assert.That(captured.InvokeRequired).IsFalse(); + await Assert.That(captured.Slot).IsSameReferenceAs(captured.Bound); + await Assert.That(captured.Rebound).IsSameReferenceAs(captured.Bound); + } + + /// Once an STA thread binds Main, every thread gets the same sequencer. + /// The test operation. + [Test] + public async Task MainIsSharedWithEveryThreadOnceAnStaThreadBindsIt() + { + var onSta = await RunOnStaThread(static () => ControlSequencer.Main); + var onPool = await Task.Run(static () => ControlSequencer.Main); + + await Assert.That(onPool).IsSameReferenceAs(onSta); + } + + /// Runs a function on a fresh STA thread and returns its result or exception. + /// The result type. + /// The function to run. + /// The function's result. + private static Task RunOnStaThread(Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + Thread thread = new(() => + { + try + { + completion.SetResult(func()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return completion.Task; + } + /// Creates a control without changing the caller's synchronization context. /// The control. private static Control CreateControl() diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs index d5a8b5da..65b7932c 100644 --- a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs @@ -182,6 +182,82 @@ public async Task DelayedScheduleWaitsForItsCallback(bool cancel) await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); } + /// A thread that is not STA gets an error, and nothing is cached. + /// The calling thread's apartment state. + /// The test operation. + [Test] + [Arguments(ApartmentState.MTA)] + [Arguments(ApartmentState.Unknown)] + public async Task BindMainRejectsAThreadThatIsNotStaAndCachesNothing(ApartmentState apartment) + { + ControlSequencer? slot = null; + InvalidOperationException? error = null; + try + { + _ = ControlSequencer.BindMain(ref slot, apartment); + } + catch (InvalidOperationException ex) + { + error = ex; + } + + await Assert.That(error).IsNotNull(); + await Assert.That(slot).IsNull(); + } + + /// On an STA thread, Main owns a hidden control whose handle belongs to that thread, and the first binding stays bound. + /// The test operation. + [Test] + public async Task BindMainOnAnStaThreadCreatesTheHandleOnThatThread() + { + var captured = await RunOnStaThread(static () => + { + ControlSequencer? slot = null; + var bound = ControlSequencer.BindMain(ref slot, ApartmentState.STA); + var rebound = ControlSequencer.BindMain(ref slot, ApartmentState.STA); + return (Bound: bound, Rebound: rebound, Slot: slot, bound.Control.IsHandleCreated, bound.Control.InvokeRequired); + }); + + await Assert.That(captured.IsHandleCreated).IsTrue(); + await Assert.That(captured.InvokeRequired).IsFalse(); + await Assert.That(captured.Slot).IsSameReferenceAs(captured.Bound); + await Assert.That(captured.Rebound).IsSameReferenceAs(captured.Bound); + } + + /// Once an STA thread binds Main, every thread gets the same sequencer. + /// The test operation. + [Test] + public async Task MainIsSharedWithEveryThreadOnceAnStaThreadBindsIt() + { + var onSta = await RunOnStaThread(static () => ControlSequencer.Main); + var onPool = await Task.Run(static () => ControlSequencer.Main); + + await Assert.That(onPool).IsSameReferenceAs(onSta); + } + + /// Runs a function on a fresh STA thread and returns its result or exception. + /// The result type. + /// The function to run. + /// The function's result. + private static Task RunOnStaThread(Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + Thread thread = new(() => + { + try + { + completion.SetResult(func()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return completion.Task; + } + /// Invokes the getter used by the debugger without reflection. /// The sequencer to display. /// The debugger display text. diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs index ca82101c..99e10991 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs @@ -146,6 +146,142 @@ public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel) handle.Dispose(); } + /// A thread without a dispatcher queue gets an error rather than a sequencer that could never run work. + /// The test operation. + [Test] + public async Task CurrentThrowsWhenTheThreadHasNoDispatcherQueue() => + await Assert.That(static () => RunOnNewThread(static () => DispatcherQueueSequencer.Current)) + .ThrowsExactly(); + + /// Each dispatcher queue thread gets its own cached sequencer bound to that thread's queue. + /// The test operation. + [Test] + public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsQueue() + { + var firstController = DispatcherQueueController.CreateOnDedicatedThread(); + var secondController = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + var first = await RunOnQueue(firstController.DispatcherQueue, CaptureCurrent); + var second = await RunOnQueue(secondController.DispatcherQueue, CaptureCurrent); + + await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer); + await Assert.That(first.Sequencer.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue); + await Assert.That(first.Sequencer.Priority).IsEqualTo(DispatcherQueuePriority.Normal); + await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer); + await Assert.That(second.Sequencer.DispatcherQueue).IsSameReferenceAs(secondController.DispatcherQueue); + } + finally + { + await firstController.ShutdownQueueAsync(); + await secondController.ShutdownQueueAsync(); + } + } + + /// Once a UI thread binds Main, a thread without a dispatcher queue gets the same sequencer. + /// The test operation. + [Test] + public async Task MainIsSharedWithEveryThreadOnceAUiThreadBindsIt() + { + var controller = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + var onQueue = await RunOnQueue(controller.DispatcherQueue, static () => DispatcherQueueSequencer.Main); + var offQueue = await RunOnNewThread(static () => DispatcherQueueSequencer.Main); + + await Assert.That(offQueue).IsSameReferenceAs(onQueue); + } + finally + { + await controller.ShutdownQueueAsync(); + } + } + + /// Without a dispatcher queue there is nothing to bind, and nothing is cached. + /// The test operation. + [Test] + public async Task BindMainReturnsNullWithoutADispatcherQueue() + { + DispatcherQueueSequencer? slot = null; + await Assert.That(DispatcherQueueSequencer.BindMain(ref slot, null)).IsNull(); + await Assert.That(slot).IsNull(); + } + + /// The first dispatcher queue bound stays bound. + /// The test operation. + [Test] + public async Task BindMainKeepsTheFirstBinding() + { + var firstController = DispatcherQueueController.CreateOnDedicatedThread(); + var secondController = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + DispatcherQueueSequencer? slot = null; + + var bound = DispatcherQueueSequencer.BindMain(ref slot, firstController.DispatcherQueue); + var rebound = DispatcherQueueSequencer.BindMain(ref slot, secondController.DispatcherQueue); + + await Assert.That(bound).IsNotNull(); + await Assert.That(bound!.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue); + await Assert.That(slot).IsSameReferenceAs(bound); + await Assert.That(rebound).IsSameReferenceAs(bound); + } + finally + { + await firstController.ShutdownQueueAsync(); + await secondController.ShutdownQueueAsync(); + } + } + + /// Reads twice on the calling thread. + /// Both reads. + private static (DispatcherQueueSequencer Sequencer, DispatcherQueueSequencer Repeat) CaptureCurrent() => + (DispatcherQueueSequencer.Current, DispatcherQueueSequencer.Current); + + /// Runs a function on a dispatcher queue's thread and returns its result or exception. + /// The result type. + /// The dispatcher queue whose thread runs the function. + /// The function to run. + /// The function's result. + private static Task RunOnQueue(DispatcherQueue queue, Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + if (!queue.TryEnqueue(() => Complete(completion, func))) + { + completion.SetException(new InvalidOperationException("The dispatcher queue rejected the work.")); + } + + return completion.Task; + } + + /// Runs a function on a fresh thread that has no dispatcher queue and returns its result or exception. + /// The result type. + /// The function to run. + /// The function's result. + private static Task RunOnNewThread(Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + Thread thread = new(() => Complete(completion, func)); + thread.Start(); + return completion.Task; + } + + /// Completes a task with a function's result or exception. + /// The result type. + /// The task to complete. + /// The function to run. + private static void Complete(TaskCompletionSource completion, Func func) + { + try + { + completion.SetResult(func()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + } + /// Retains callbacks until they are explicitly delivered. private sealed class ManualDispatcher { diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs index 8f903a21..35e04c65 100644 --- a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs @@ -148,6 +148,142 @@ public async Task DelayedScheduleWaitsForItsCallback(bool cancel) await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); } + /// A thread without a dispatcher queue gets an error rather than a sequencer that could never run work. + /// The test operation. + [Test] + public async Task CurrentThrowsWhenTheThreadHasNoDispatcherQueue() => + await Assert.That(static () => RunOnNewThread(static () => DispatcherQueueSequencer.Current)) + .ThrowsExactly(); + + /// Each dispatcher queue thread gets its own cached sequencer bound to that thread's queue. + /// The test operation. + [Test] + public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsQueue() + { + var firstController = DispatcherQueueController.CreateOnDedicatedThread(); + var secondController = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + var first = await RunOnQueue(firstController.DispatcherQueue, CaptureCurrent); + var second = await RunOnQueue(secondController.DispatcherQueue, CaptureCurrent); + + await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer); + await Assert.That(first.Sequencer.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue); + await Assert.That(first.Sequencer.Priority).IsEqualTo(DispatcherQueuePriority.Normal); + await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer); + await Assert.That(second.Sequencer.DispatcherQueue).IsSameReferenceAs(secondController.DispatcherQueue); + } + finally + { + await firstController.ShutdownQueueAsync(); + await secondController.ShutdownQueueAsync(); + } + } + + /// Once a UI thread binds Main, a thread without a dispatcher queue gets the same sequencer. + /// The test operation. + [Test] + public async Task MainIsSharedWithEveryThreadOnceAUiThreadBindsIt() + { + var controller = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + var onQueue = await RunOnQueue(controller.DispatcherQueue, static () => DispatcherQueueSequencer.Main); + var offQueue = await RunOnNewThread(static () => DispatcherQueueSequencer.Main); + + await Assert.That(offQueue).IsSameReferenceAs(onQueue); + } + finally + { + await controller.ShutdownQueueAsync(); + } + } + + /// Without a dispatcher queue there is nothing to bind, and nothing is cached. + /// The test operation. + [Test] + public async Task BindMainReturnsNullWithoutADispatcherQueue() + { + DispatcherQueueSequencer? slot = null; + await Assert.That(DispatcherQueueSequencer.BindMain(ref slot, null)).IsNull(); + await Assert.That(slot).IsNull(); + } + + /// The first dispatcher queue bound stays bound. + /// The test operation. + [Test] + public async Task BindMainKeepsTheFirstBinding() + { + var firstController = DispatcherQueueController.CreateOnDedicatedThread(); + var secondController = DispatcherQueueController.CreateOnDedicatedThread(); + try + { + DispatcherQueueSequencer? slot = null; + + var bound = DispatcherQueueSequencer.BindMain(ref slot, firstController.DispatcherQueue); + var rebound = DispatcherQueueSequencer.BindMain(ref slot, secondController.DispatcherQueue); + + await Assert.That(bound).IsNotNull(); + await Assert.That(bound!.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue); + await Assert.That(slot).IsSameReferenceAs(bound); + await Assert.That(rebound).IsSameReferenceAs(bound); + } + finally + { + await firstController.ShutdownQueueAsync(); + await secondController.ShutdownQueueAsync(); + } + } + + /// Reads twice on the calling thread. + /// Both reads. + private static (DispatcherQueueSequencer Sequencer, DispatcherQueueSequencer Repeat) CaptureCurrent() => + (DispatcherQueueSequencer.Current, DispatcherQueueSequencer.Current); + + /// Runs a function on a dispatcher queue's thread and returns its result or exception. + /// The result type. + /// The dispatcher queue whose thread runs the function. + /// The function to run. + /// The function's result. + private static Task RunOnQueue(DispatcherQueue queue, Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + if (!queue.TryEnqueue(() => Complete(completion, func))) + { + completion.SetException(new InvalidOperationException("The dispatcher queue rejected the work.")); + } + + return completion.Task; + } + + /// Runs a function on a fresh thread that has no dispatcher queue and returns its result or exception. + /// The result type. + /// The function to run. + /// The function's result. + private static Task RunOnNewThread(Func func) + { + TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + Thread thread = new(() => Complete(completion, func)); + thread.Start(); + return completion.Task; + } + + /// Completes a task with a function's result or exception. + /// The result type. + /// The task to complete. + /// The function to run. + private static void Complete(TaskCompletionSource completion, Func func) + { + try + { + completion.SetResult(func()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + } + /// Retains callbacks until they are explicitly delivered. private sealed class ManualDispatcher {