From 18d73a388b2b7cc1b724faf5956ba434c6e729f9 Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 21 Apr 2026 18:40:10 -0400 Subject: [PATCH 1/4] Fix MainThread throwing on custom platform backends Bridge the MAUI application dispatcher to MainThread.IsMainThread and MainThread.BeginInvokeOnMainThread so they work on custom platform backends / external TFMs (e.g. Linux/GTK) where no native MainThread implementation exists. Changes: - Add internal backing delegates (SetCustomImplementation/ClearCustom- Implementation) to MainThread.shared.cs - Modify MainThread.netstandard.cs to check backing delegates before throwing NotImplementedInReferenceAssemblyException - Bridge the IDispatcherProvider to MainThread during Essentials initialization in EssentialsMauiAppBuilderExtensions.cs - Add InternalsVisibleTo from Essentials to Core assembly - Add 11 focused regression tests in MainThreadBridgeTests.cs On supported platforms (Android, iOS, Windows, Tizen), the platform- specific partial methods take precedence and the backing delegates are never consulted, preserving existing behavior. Fixes #34101 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EssentialsMauiAppBuilderExtensions.cs | 23 +++ .../Hosting/MainThreadBridgeTests.cs | 192 ++++++++++++++++++ .../src/AssemblyInfo/AssemblyInfo.shared.cs | 2 + .../src/MainThread/MainThread.netstandard.cs | 14 +- .../src/MainThread/MainThread.shared.cs | 18 ++ 5 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index 92f8344518e4..5ac1e1e9bc90 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Dispatching; using Microsoft.Maui.Hosting; using Microsoft.Maui.LifecycleEvents; #if ANDROID @@ -135,6 +136,8 @@ public void Initialize(IServiceProvider services) } } + BridgeMainThreadFromDispatcher(services); + #if WINDOWS ApplicationModel.Platform.MapServiceToken = _essentialsBuilder.MapServiceToken; #endif @@ -152,6 +155,26 @@ public void Initialize(IServiceProvider services) VersionTracking.Track(); } + /// + /// Bridges the MAUI application dispatcher to MainThread so that + /// MainThread.BeginInvokeOnMainThread and MainThread.IsMainThread work + /// on custom platform backends / external TFMs where no native + /// MainThread implementation exists. + /// On supported platforms the Platform* methods take precedence and + /// the backing delegates are never consulted. + /// + static void BridgeMainThreadFromDispatcher(IServiceProvider services) + { + var dispatcherProvider = services.GetService(); + var dispatcher = dispatcherProvider?.GetForCurrentThread(); + if (dispatcher is null) + return; + + MainThread.SetCustomImplementation( + isMainThread: () => !dispatcher.IsDispatchRequired, + beginInvokeOnMainThread: action => dispatcher.Dispatch(action)); + } + private static async void SetAppActions(IServiceProvider services, List appActions) { try diff --git a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs new file mode 100644 index 000000000000..db08cd19a49f --- /dev/null +++ b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Dispatching; +using Microsoft.Maui.Hosting; +using Xunit; + +namespace Microsoft.Maui.UnitTests.Hosting +{ + [Category(TestCategory.Core, TestCategory.Hosting)] + public class MainThreadBridgeTests : IDisposable + { + public MainThreadBridgeTests() + { + // Ensure clean state before each test + MainThread.ClearCustomImplementation(); + } + + public void Dispose() + { + MainThread.ClearCustomImplementation(); + DispatcherProvider.SetCurrent(null); + } + + [Fact] + public void WithoutCustomImpl_IsMainThread_Throws() + { + // On netstandard with no backing implementation, IsMainThread should throw + Assert.Throws( + () => _ = MainThread.IsMainThread); + } + + [Fact] + public void WithoutCustomImpl_BeginInvoke_Throws() + { + // On netstandard with no backing implementation, BeginInvokeOnMainThread should throw + Assert.Throws( + () => MainThread.BeginInvokeOnMainThread(() => { })); + } + + [Fact] + public void WithCustomImpl_IsMainThread_UsesBackingImpl() + { + MainThread.SetCustomImplementation( + isMainThread: () => true, + beginInvokeOnMainThread: _ => { }); + + Assert.True(MainThread.IsMainThread); + } + + [Fact] + public void WithCustomImpl_IsMainThread_ReturnsFalse() + { + MainThread.SetCustomImplementation( + isMainThread: () => false, + beginInvokeOnMainThread: _ => { }); + + Assert.False(MainThread.IsMainThread); + } + + [Fact] + public void WithCustomImpl_BeginInvoke_CallsBackingImpl() + { + var invoked = false; + Action capturedAction = null; + + MainThread.SetCustomImplementation( + isMainThread: () => false, + beginInvokeOnMainThread: action => capturedAction = action); + + MainThread.BeginInvokeOnMainThread(() => invoked = true); + + // The custom impl captured the action; execute it + Assert.NotNull(capturedAction); + capturedAction(); + Assert.True(invoked); + } + + [Fact] + public void BeginInvoke_WhenOnMainThread_InvokesDirectly() + { + var invoked = false; + + MainThread.SetCustomImplementation( + isMainThread: () => true, + beginInvokeOnMainThread: _ => throw new InvalidOperationException("Should not be called")); + + // When IsMainThread returns true, the shared code invokes the action directly + MainThread.BeginInvokeOnMainThread(() => invoked = true); + + Assert.True(invoked); + } + + [Fact] + public void ClearCustomImpl_RestoresThrowBehavior() + { + MainThread.SetCustomImplementation( + isMainThread: () => true, + beginInvokeOnMainThread: _ => { }); + + Assert.True(MainThread.IsMainThread); + + MainThread.ClearCustomImplementation(); + + Assert.Throws( + () => _ = MainThread.IsMainThread); + } + + [Fact] + public void SetCustomImpl_NullIsMainThread_Throws() + { + Assert.Throws( + () => MainThread.SetCustomImplementation(null, _ => { })); + } + + [Fact] + public void SetCustomImpl_NullBeginInvoke_Throws() + { + Assert.Throws( + () => MainThread.SetCustomImplementation(() => true, null)); + } + + [Fact] + public void MauiAppBuild_BridgesDispatcherToMainThread() + { + // Set up a dispatcher provider that returns a real dispatcher stub + var dispatcherStub = new DispatcherStub( + isInvokeRequired: () => false, + invokeOnMainThread: null); + + var dispatcherProvider = new TestDispatcherProvider(dispatcherStub); + DispatcherProvider.SetCurrent(dispatcherProvider); + + try + { + var builder = MauiApp.CreateBuilder(); + builder.ConfigureEssentials(); + using var app = builder.Build(); + + // After MauiApp.Build(), the bridge should have connected MainThread + // to the dispatcher. IsMainThread should return true (since IsDispatchRequired is false). + Assert.True(MainThread.IsMainThread); + } + finally + { + DispatcherProvider.SetCurrent(null); + } + } + + [Fact] + public void MauiAppBuild_BeginInvoke_DispatchesToDispatcher() + { + var dispatched = false; + var dispatcherStub = new DispatcherStub( + isInvokeRequired: () => true, + invokeOnMainThread: action => { dispatched = true; action(); }); + + var dispatcherProvider = new TestDispatcherProvider(dispatcherStub); + DispatcherProvider.SetCurrent(dispatcherProvider); + + try + { + var builder = MauiApp.CreateBuilder(); + builder.ConfigureEssentials(); + using var app = builder.Build(); + + var actionExecuted = false; + MainThread.BeginInvokeOnMainThread(() => actionExecuted = true); + + Assert.True(dispatched); + Assert.True(actionExecuted); + } + finally + { + DispatcherProvider.SetCurrent(null); + } + } + + class TestDispatcherProvider : IDispatcherProvider + { + readonly IDispatcher _dispatcher; + + public TestDispatcherProvider(IDispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + public IDispatcher GetForCurrentThread() => _dispatcher; + } + } +} diff --git a/src/Essentials/src/AssemblyInfo/AssemblyInfo.shared.cs b/src/Essentials/src/AssemblyInfo/AssemblyInfo.shared.cs index 4a4ab0f715e2..7249c3858fa2 100644 --- a/src/Essentials/src/AssemblyInfo/AssemblyInfo.shared.cs +++ b/src/Essentials/src/AssemblyInfo/AssemblyInfo.shared.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Microsoft.Maui")] [assembly: InternalsVisibleTo("Microsoft.Maui.Essentials.DeviceTests")] [assembly: InternalsVisibleTo("Microsoft.Maui.Essentials.UnitTests")] [assembly: InternalsVisibleTo("EssentialsTests")] @@ -18,3 +19,4 @@ [assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils")] [assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils.DeviceTests")] [assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils.DeviceTests.Runners")] +[assembly: InternalsVisibleTo("Microsoft.Maui.UnitTests")] diff --git a/src/Essentials/src/MainThread/MainThread.netstandard.cs b/src/Essentials/src/MainThread/MainThread.netstandard.cs index 589868ca2e54..5055591e6a91 100644 --- a/src/Essentials/src/MainThread/MainThread.netstandard.cs +++ b/src/Essentials/src/MainThread/MainThread.netstandard.cs @@ -4,10 +4,18 @@ namespace Microsoft.Maui.ApplicationModel { public static partial class MainThread { - static void PlatformBeginInvokeOnMainThread(Action action) => - throw ExceptionUtils.NotSupportedOrImplementedException; - static bool PlatformIsMainThread => + s_isMainThreadImpl != null ? s_isMainThreadImpl.Invoke() : throw ExceptionUtils.NotSupportedOrImplementedException; + + static void PlatformBeginInvokeOnMainThread(Action action) + { + if (s_beginInvokeOnMainThreadImpl != null) + { + s_beginInvokeOnMainThreadImpl(action); + return; + } + throw ExceptionUtils.NotSupportedOrImplementedException; + } } } diff --git a/src/Essentials/src/MainThread/MainThread.shared.cs b/src/Essentials/src/MainThread/MainThread.shared.cs index 3f07d802e34f..456130536ddb 100644 --- a/src/Essentials/src/MainThread/MainThread.shared.cs +++ b/src/Essentials/src/MainThread/MainThread.shared.cs @@ -9,6 +9,24 @@ namespace Microsoft.Maui.ApplicationModel /// public static partial class MainThread { + // Internal backing for custom platform backends and dispatcher fallback. + // On supported platforms (Android, iOS, Windows), the Platform* methods are used directly. + // On netstandard/external TFMs, these delegates provide the implementation. + static Func s_isMainThreadImpl; + static Action s_beginInvokeOnMainThreadImpl; + + internal static void SetCustomImplementation(Func isMainThread, Action beginInvokeOnMainThread) + { + s_isMainThreadImpl = isMainThread ?? throw new ArgumentNullException(nameof(isMainThread)); + s_beginInvokeOnMainThreadImpl = beginInvokeOnMainThread ?? throw new ArgumentNullException(nameof(beginInvokeOnMainThread)); + } + + internal static void ClearCustomImplementation() + { + s_isMainThreadImpl = null; + s_beginInvokeOnMainThreadImpl = null; + } + /// /// True if the current thread is the UI thread. /// From 689e29f0a6a85e24430af6d3a3f49a70f56dc4fb Mon Sep 17 00:00:00 2001 From: redth Date: Tue, 21 Apr 2026 18:57:45 -0400 Subject: [PATCH 2/4] Address self-review findings for MainThread bridge Ensure the MainThread dispatcher bridge is registered for the default Essentials startup path and harden the injected implementation state against partial cross-thread visibility during initialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EssentialsMauiAppBuilderExtensions.cs | 3 ++ .../Hosting/MainThreadBridgeTests.cs | 2 -- .../src/MainThread/MainThread.netstandard.cs | 14 ++++----- .../src/MainThread/MainThread.shared.cs | 29 ++++++++++++++----- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index 5ac1e1e9bc90..a97d5e3d02b6 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -83,6 +83,8 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder) #endif }); + builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); + return builder; } @@ -92,6 +94,7 @@ public static MauiAppBuilder ConfigureEssentials(this MauiAppBuilder builder, Ac { builder.Services.AddSingleton(new EssentialsRegistration(configureDelegate)); } + builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); return builder; diff --git a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs index db08cd19a49f..d07bf08a4087 100644 --- a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs +++ b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs @@ -135,7 +135,6 @@ public void MauiAppBuild_BridgesDispatcherToMainThread() try { var builder = MauiApp.CreateBuilder(); - builder.ConfigureEssentials(); using var app = builder.Build(); // After MauiApp.Build(), the bridge should have connected MainThread @@ -162,7 +161,6 @@ public void MauiAppBuild_BeginInvoke_DispatchesToDispatcher() try { var builder = MauiApp.CreateBuilder(); - builder.ConfigureEssentials(); using var app = builder.Build(); var actionExecuted = false; diff --git a/src/Essentials/src/MainThread/MainThread.netstandard.cs b/src/Essentials/src/MainThread/MainThread.netstandard.cs index 5055591e6a91..0ef5bd30e3d5 100644 --- a/src/Essentials/src/MainThread/MainThread.netstandard.cs +++ b/src/Essentials/src/MainThread/MainThread.netstandard.cs @@ -5,17 +5,15 @@ namespace Microsoft.Maui.ApplicationModel public static partial class MainThread { static bool PlatformIsMainThread => - s_isMainThreadImpl != null ? s_isMainThreadImpl.Invoke() : throw ExceptionUtils.NotSupportedOrImplementedException; + s_mainThreadImplementation?.IsMainThread() ?? throw ExceptionUtils.NotSupportedOrImplementedException; static void PlatformBeginInvokeOnMainThread(Action action) { - if (s_beginInvokeOnMainThreadImpl != null) - { - s_beginInvokeOnMainThreadImpl(action); - return; - } - - throw ExceptionUtils.NotSupportedOrImplementedException; + var implementation = s_mainThreadImplementation; + if (implementation is not null) + implementation.BeginInvokeOnMainThread(action); + else + throw ExceptionUtils.NotSupportedOrImplementedException; } } } diff --git a/src/Essentials/src/MainThread/MainThread.shared.cs b/src/Essentials/src/MainThread/MainThread.shared.cs index 456130536ddb..49507f7d11fb 100644 --- a/src/Essentials/src/MainThread/MainThread.shared.cs +++ b/src/Essentials/src/MainThread/MainThread.shared.cs @@ -11,20 +11,35 @@ public static partial class MainThread { // Internal backing for custom platform backends and dispatcher fallback. // On supported platforms (Android, iOS, Windows), the Platform* methods are used directly. - // On netstandard/external TFMs, these delegates provide the implementation. - static Func s_isMainThreadImpl; - static Action s_beginInvokeOnMainThreadImpl; + // On netstandard/external TFMs, this provides the implementation as a single atomic state object. + static volatile MainThreadImplementation s_mainThreadImplementation; + + sealed class MainThreadImplementation + { + readonly Func _isMainThread; + readonly Action _beginInvokeOnMainThread; + + public MainThreadImplementation(Func isMainThread, Action beginInvokeOnMainThread) + { + _isMainThread = isMainThread; + _beginInvokeOnMainThread = beginInvokeOnMainThread; + } + + public bool IsMainThread() => _isMainThread(); + + public void BeginInvokeOnMainThread(Action action) => _beginInvokeOnMainThread(action); + } internal static void SetCustomImplementation(Func isMainThread, Action beginInvokeOnMainThread) { - s_isMainThreadImpl = isMainThread ?? throw new ArgumentNullException(nameof(isMainThread)); - s_beginInvokeOnMainThreadImpl = beginInvokeOnMainThread ?? throw new ArgumentNullException(nameof(beginInvokeOnMainThread)); + s_mainThreadImplementation = new MainThreadImplementation( + isMainThread ?? throw new ArgumentNullException(nameof(isMainThread)), + beginInvokeOnMainThread ?? throw new ArgumentNullException(nameof(beginInvokeOnMainThread))); } internal static void ClearCustomImplementation() { - s_isMainThreadImpl = null; - s_beginInvokeOnMainThreadImpl = null; + s_mainThreadImplementation = null; } /// From 3566720e5a79f7fc37f68458034cadd26495197b Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 22 Apr 2026 09:35:13 -0400 Subject: [PATCH 3/4] Address PR review feedback for MainThread bridge - make custom MainThread implementation publication atomic with Volatile - use the cached application dispatcher for the bridge - limit the bridge to unsupported/custom platform backends - tighten nullable/test cleanup in MainThreadBridgeTests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EssentialsMauiAppBuilderExtensions.cs | 9 +-- .../Hosting/MainThreadBridgeTests.cs | 57 ++++++++++++++++--- .../src/MainThread/MainThread.netstandard.cs | 16 +++++- .../src/MainThread/MainThread.shared.cs | 8 +-- 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index a97d5e3d02b6..51a2d718811e 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -139,7 +139,9 @@ public void Initialize(IServiceProvider services) } } +#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN) BridgeMainThreadFromDispatcher(services); +#endif #if WINDOWS ApplicationModel.Platform.MapServiceToken = _essentialsBuilder.MapServiceToken; @@ -163,13 +165,11 @@ public void Initialize(IServiceProvider services) /// MainThread.BeginInvokeOnMainThread and MainThread.IsMainThread work /// on custom platform backends / external TFMs where no native /// MainThread implementation exists. - /// On supported platforms the Platform* methods take precedence and - /// the backing delegates are never consulted. /// +#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN) static void BridgeMainThreadFromDispatcher(IServiceProvider services) { - var dispatcherProvider = services.GetService(); - var dispatcher = dispatcherProvider?.GetForCurrentThread(); + var dispatcher = services.GetOptionalApplicationDispatcher(); if (dispatcher is null) return; @@ -177,6 +177,7 @@ static void BridgeMainThreadFromDispatcher(IServiceProvider services) isMainThread: () => !dispatcher.IsDispatchRequired, beginInvokeOnMainThread: action => dispatcher.Dispatch(action)); } +#endif private static async void SetAppActions(IServiceProvider services, List appActions) { diff --git a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs index d07bf08a4087..97c032968be7 100644 --- a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs +++ b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs @@ -1,6 +1,6 @@ +#nullable enable + using System; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Dispatching; using Microsoft.Maui.Hosting; @@ -63,7 +63,7 @@ public void WithCustomImpl_IsMainThread_ReturnsFalse() public void WithCustomImpl_BeginInvoke_CallsBackingImpl() { var invoked = false; - Action capturedAction = null; + Action? capturedAction = null; MainThread.SetCustomImplementation( isMainThread: () => false, @@ -73,7 +73,7 @@ public void WithCustomImpl_BeginInvoke_CallsBackingImpl() // The custom impl captured the action; execute it Assert.NotNull(capturedAction); - capturedAction(); + capturedAction!(); Assert.True(invoked); } @@ -111,14 +111,14 @@ public void ClearCustomImpl_RestoresThrowBehavior() public void SetCustomImpl_NullIsMainThread_Throws() { Assert.Throws( - () => MainThread.SetCustomImplementation(null, _ => { })); + () => MainThread.SetCustomImplementation(null!, _ => { })); } [Fact] public void SetCustomImpl_NullBeginInvoke_Throws() { Assert.Throws( - () => MainThread.SetCustomImplementation(() => true, null)); + () => MainThread.SetCustomImplementation(() => true, null!)); } [Fact] @@ -175,6 +175,29 @@ public void MauiAppBuild_BeginInvoke_DispatchesToDispatcher() } } + [Fact] + public void MauiAppBuild_UsesCachedApplicationDispatcherForBridge() + { + var dispatcherStub = new DispatcherStub( + isInvokeRequired: () => false, + invokeOnMainThread: action => action()); + + var dispatcherProvider = new SequencedDispatcherProvider(dispatcherStub, null); + DispatcherProvider.SetCurrent(dispatcherProvider); + + try + { + var builder = MauiApp.CreateBuilder(); + using var app = builder.Build(); + + Assert.True(MainThread.IsMainThread); + } + finally + { + DispatcherProvider.SetCurrent(null); + } + } + class TestDispatcherProvider : IDispatcherProvider { readonly IDispatcher _dispatcher; @@ -184,7 +207,27 @@ public TestDispatcherProvider(IDispatcher dispatcher) _dispatcher = dispatcher; } - public IDispatcher GetForCurrentThread() => _dispatcher; + public IDispatcher? GetForCurrentThread() => _dispatcher; + } + + class SequencedDispatcherProvider : IDispatcherProvider + { + readonly IDispatcher?[] _dispatchers; + int _index; + + public SequencedDispatcherProvider(params IDispatcher?[] dispatchers) + { + _dispatchers = dispatchers; + } + + public IDispatcher? GetForCurrentThread() + { + var index = _index++; + if (index >= _dispatchers.Length) + index = _dispatchers.Length - 1; + + return index >= 0 ? _dispatchers[index] : null; + } } } } diff --git a/src/Essentials/src/MainThread/MainThread.netstandard.cs b/src/Essentials/src/MainThread/MainThread.netstandard.cs index 0ef5bd30e3d5..49a0b2e912fd 100644 --- a/src/Essentials/src/MainThread/MainThread.netstandard.cs +++ b/src/Essentials/src/MainThread/MainThread.netstandard.cs @@ -1,15 +1,25 @@ using System; +using System.Threading; namespace Microsoft.Maui.ApplicationModel { public static partial class MainThread { - static bool PlatformIsMainThread => - s_mainThreadImplementation?.IsMainThread() ?? throw ExceptionUtils.NotSupportedOrImplementedException; + static bool PlatformIsMainThread + { + get + { + var implementation = Volatile.Read(ref s_mainThreadImplementation); + if (implementation is not null) + return implementation.IsMainThread(); + + throw ExceptionUtils.NotSupportedOrImplementedException; + } + } static void PlatformBeginInvokeOnMainThread(Action action) { - var implementation = s_mainThreadImplementation; + var implementation = Volatile.Read(ref s_mainThreadImplementation); if (implementation is not null) implementation.BeginInvokeOnMainThread(action); else diff --git a/src/Essentials/src/MainThread/MainThread.shared.cs b/src/Essentials/src/MainThread/MainThread.shared.cs index 49507f7d11fb..f39c342168eb 100644 --- a/src/Essentials/src/MainThread/MainThread.shared.cs +++ b/src/Essentials/src/MainThread/MainThread.shared.cs @@ -12,7 +12,7 @@ public static partial class MainThread // Internal backing for custom platform backends and dispatcher fallback. // On supported platforms (Android, iOS, Windows), the Platform* methods are used directly. // On netstandard/external TFMs, this provides the implementation as a single atomic state object. - static volatile MainThreadImplementation s_mainThreadImplementation; + static MainThreadImplementation s_mainThreadImplementation; sealed class MainThreadImplementation { @@ -32,14 +32,14 @@ public MainThreadImplementation(Func isMainThread, Action beginInv internal static void SetCustomImplementation(Func isMainThread, Action beginInvokeOnMainThread) { - s_mainThreadImplementation = new MainThreadImplementation( + Volatile.Write(ref s_mainThreadImplementation, new MainThreadImplementation( isMainThread ?? throw new ArgumentNullException(nameof(isMainThread)), - beginInvokeOnMainThread ?? throw new ArgumentNullException(nameof(beginInvokeOnMainThread))); + beginInvokeOnMainThread ?? throw new ArgumentNullException(nameof(beginInvokeOnMainThread)))); } internal static void ClearCustomImplementation() { - s_mainThreadImplementation = null; + Volatile.Write(ref s_mainThreadImplementation, null); } /// From c7bbfc00c663c17910cf2533ab0649609ffc78ef Mon Sep 17 00:00:00 2001 From: redth Date: Thu, 23 Apr 2026 10:07:35 -0400 Subject: [PATCH 4/4] Address multi-model review: extract MainThreadBridgeInitializer, add async test, improve docs - Extract MainThreadBridgeInitializer from EssentialsInitializer so the bridge does not cause EssentialsInitializer to run unconditionally - Add InvokeOnMainThreadAsync integration test through the bridge - Rename misleading test and remove unused SequencedDispatcherProvider - Add nullable annotation to s_mainThreadImplementation field - Document lifetime assumption for the bridge reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EssentialsMauiAppBuilderExtensions.cs | 49 ++++++++--------- .../Hosting/MainThreadBridgeTests.cs | 54 +++++++++++-------- .../src/MainThread/MainThread.shared.cs | 10 +++- 3 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index 51a2d718811e..d18996432efd 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -83,7 +83,9 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder) #endif }); - builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); +#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN) + builder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); +#endif return builder; } @@ -118,6 +120,28 @@ internal void RegisterEssentialsOptions(IEssentialsBuilder essentials) } } + /// + /// Lightweight initializer that bridges the MAUI application dispatcher to MainThread + /// so that MainThread.BeginInvokeOnMainThread and MainThread.IsMainThread work + /// on custom platform backends / external TFMs where no native + /// MainThread implementation exists. + /// +#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN) + class MainThreadBridgeInitializer : IMauiInitializeService + { + public void Initialize(IServiceProvider services) + { + var dispatcher = services.GetOptionalApplicationDispatcher(); + if (dispatcher is null) + return; + + MainThread.SetCustomImplementation( + isMainThread: () => !dispatcher.IsDispatchRequired, + beginInvokeOnMainThread: action => dispatcher.Dispatch(action)); + } + } +#endif + class EssentialsInitializer : IMauiInitializeService { private readonly IEnumerable _essentialsRegistrations; @@ -139,10 +163,6 @@ public void Initialize(IServiceProvider services) } } -#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN) - BridgeMainThreadFromDispatcher(services); -#endif - #if WINDOWS ApplicationModel.Platform.MapServiceToken = _essentialsBuilder.MapServiceToken; #endif @@ -160,25 +180,6 @@ public void Initialize(IServiceProvider services) VersionTracking.Track(); } - /// - /// Bridges the MAUI application dispatcher to MainThread so that - /// MainThread.BeginInvokeOnMainThread and MainThread.IsMainThread work - /// on custom platform backends / external TFMs where no native - /// MainThread implementation exists. - /// -#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN) - static void BridgeMainThreadFromDispatcher(IServiceProvider services) - { - var dispatcher = services.GetOptionalApplicationDispatcher(); - if (dispatcher is null) - return; - - MainThread.SetCustomImplementation( - isMainThread: () => !dispatcher.IsDispatchRequired, - beginInvokeOnMainThread: action => dispatcher.Dispatch(action)); - } -#endif - private static async void SetAppActions(IServiceProvider services, List appActions) { try diff --git a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs index 97c032968be7..5ad82f9cfe28 100644 --- a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs +++ b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Threading.Tasks; using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Dispatching; using Microsoft.Maui.Hosting; @@ -176,13 +177,41 @@ public void MauiAppBuild_BeginInvoke_DispatchesToDispatcher() } [Fact] - public void MauiAppBuild_UsesCachedApplicationDispatcherForBridge() + public async Task InvokeOnMainThreadAsync_Action_WorksThroughBridge() + { + var dispatched = false; + var dispatcherStub = new DispatcherStub( + isInvokeRequired: () => true, + invokeOnMainThread: action => { dispatched = true; action(); }); + + var dispatcherProvider = new TestDispatcherProvider(dispatcherStub); + DispatcherProvider.SetCurrent(dispatcherProvider); + + try + { + var builder = MauiApp.CreateBuilder(); + using var app = builder.Build(); + + var actionExecuted = false; + await MainThread.InvokeOnMainThreadAsync(() => actionExecuted = true); + + Assert.True(dispatched); + Assert.True(actionExecuted); + } + finally + { + DispatcherProvider.SetCurrent(null); + } + } + + [Fact] + public void MauiAppBuild_BridgeUsesApplicationDispatcher() { var dispatcherStub = new DispatcherStub( isInvokeRequired: () => false, invokeOnMainThread: action => action()); - var dispatcherProvider = new SequencedDispatcherProvider(dispatcherStub, null); + var dispatcherProvider = new TestDispatcherProvider(dispatcherStub); DispatcherProvider.SetCurrent(dispatcherProvider); try @@ -190,6 +219,7 @@ public void MauiAppBuild_UsesCachedApplicationDispatcherForBridge() var builder = MauiApp.CreateBuilder(); using var app = builder.Build(); + // Verify the bridge connected the dispatcher's IsDispatchRequired to MainThread.IsMainThread Assert.True(MainThread.IsMainThread); } finally @@ -209,25 +239,5 @@ public TestDispatcherProvider(IDispatcher dispatcher) public IDispatcher? GetForCurrentThread() => _dispatcher; } - - class SequencedDispatcherProvider : IDispatcherProvider - { - readonly IDispatcher?[] _dispatchers; - int _index; - - public SequencedDispatcherProvider(params IDispatcher?[] dispatchers) - { - _dispatchers = dispatchers; - } - - public IDispatcher? GetForCurrentThread() - { - var index = _index++; - if (index >= _dispatchers.Length) - index = _dispatchers.Length - 1; - - return index >= 0 ? _dispatchers[index] : null; - } - } } } diff --git a/src/Essentials/src/MainThread/MainThread.shared.cs b/src/Essentials/src/MainThread/MainThread.shared.cs index f39c342168eb..29b21d65f68f 100644 --- a/src/Essentials/src/MainThread/MainThread.shared.cs +++ b/src/Essentials/src/MainThread/MainThread.shared.cs @@ -12,7 +12,15 @@ public static partial class MainThread // Internal backing for custom platform backends and dispatcher fallback. // On supported platforms (Android, iOS, Windows), the Platform* methods are used directly. // On netstandard/external TFMs, this provides the implementation as a single atomic state object. - static MainThreadImplementation s_mainThreadImplementation; + // + // Lifetime: This field is set once during MauiApp initialization and is expected to live + // for the duration of the application. It is NOT cleared on MauiApp.Dispose() because: + // 1. Custom backends typically have a single long-lived MauiApp instance. + // 2. Rebuilding calls SetCustomImplementation again, atomically replacing the old reference. + // 3. After disposal, callers should not invoke MainThread APIs; behavior is undefined. +#nullable enable + static MainThreadImplementation? s_mainThreadImplementation; +#nullable restore sealed class MainThreadImplementation {