From 323c274a67123bac9968862629b99b286a5fed61 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:36:31 +1000 Subject: [PATCH] feat: add main-thread sequencers for MAUI, WinUI and WinForms Give MAUI, WinUI and WinForms a shared main-thread sequencer, matching DispatcherSequencer.Main on WPF, so consumers such as ReactiveUI no longer resolve the UI dispatcher themselves. Each is added to both the ISequencer and the System.Reactive IScheduler flavours. MauiDispatcherSequencer.Main binds once to the running application's dispatcher, from any thread. Until the application exists it uses the calling thread's dispatcher. It throws when neither exists, and never falls back to the thread pool. MauiDispatcherSequencer.Current returns the calling thread's dispatcher sequencer, cached per thread. DispatcherQueueSequencer.Main binds to the first dispatcher queue it sees on a UI thread. A read from a thread without a dispatcher queue throws, and nothing is cached. DispatcherQueueSequencer.Current returns the calling thread's dispatcher queue sequencer, cached per thread. ControlSequencer.Main creates its hidden control only on an STA thread, so the handle belongs to a thread that can run a message loop. A read from any other thread throws instead of binding to a thread that never pumps messages. None of these properties creates a dispatcher. --- .../Concurrency/MauiDispatcherSequencer.cs | 54 ++++++ .../PublicAPI/net10.0/PublicAPI.txt | 2 + .../PublicAPI/net11.0/PublicAPI.txt | 2 + .../Concurrency/MauiDispatcherSequencer.cs | 54 ++++++ .../PublicAPI/net10.0/PublicAPI.txt | 3 + .../PublicAPI/net11.0/PublicAPI.txt | 3 + .../ReactiveUI.Primitives.Maui.csproj | 4 + .../Concurrency/ControlSequencer.cs | 40 +++++ .../PublicAPI/net10.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net11.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net9.0-windows/PublicAPI.txt | 1 + .../Concurrency/ControlSequencer.cs | 40 +++++ .../PublicAPI/net10.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net11.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net9.0-windows/PublicAPI.txt | 1 + .../Concurrency/DispatcherQueueSequencer.cs | 46 +++++ .../net10.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../net11.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../net8.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../net9.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../Concurrency/DispatcherQueueSequencer.cs | 46 +++++ .../net10.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../net11.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../net8.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../net9.0-windows10.0.19041.0/PublicAPI.txt | 2 + .../MauiDispatcherSequencerTests.cs | 160 ++++++++++++++++++ .../MauiDispatcherSequencerTests.cs | 160 ++++++++++++++++++ .../ControlSequencerTests.cs | 76 +++++++++ .../ControlSequencerTests.cs | 76 +++++++++ .../DispatcherQueueSequencerTests.cs | 136 +++++++++++++++ .../DispatcherQueueSequencerTests.cs | 136 +++++++++++++++ 41 files changed, 1070 insertions(+) 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 {