From 9c59e6baba1b17c21e3c270054f031c39e3ae5b7 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Sun, 5 Jul 2026 19:34:02 +0100 Subject: [PATCH] feat: add reactive plugin shim project Projects: - Add Extensions.Hosting.Plugins.Reactive as a linked-source project over Extensions.Hosting.Plugins with REACTIVE_SHIM defined. - Add the ReactiveUI.Primitives.Reactive central package version and include the reactive project in the solution and test project references. - Add conditional namespaces for the shared plugin source so non-reactive and reactive assemblies expose distinct API surfaces. Quality: - Expand TUnit coverage for plugin scanning, assembly loading, hosted service disposal, plugin builder behavior, reactive shim contracts, and SingleInstance mutex fault paths. - Scope coverage configuration to Extensions.Hosting.* production assemblies while excluding examples, logging, tests, and runner artifacts. - Add a narrow internal ResourceMutex test seam so timeout, unauthorized acquisition, and release-failure paths are covered without suppressions. - Resolve analyzer findings in platform/example code and plugin internals so Release builds pass with warnings as errors. --- Directory.Packages.props | 1 + .../Internals/AvaloniaThread.cs | 1 - .../BaseUiThread.cs | 4 + ...Extensions.Hosting.Plugins.Reactive.csproj | 30 ++ .../HostBuilderPluginExtensions.cs | 10 +- .../HostedServiceBase.cs | 4 + src/Extensions.Hosting.Plugins/IPlugin.cs | 4 + .../IPluginBuilder.cs | 4 + .../Internals/AssemblyDependencyResolver.cs | 4 + .../Internals/AssemblyLoadContext.cs | 38 +- .../AssemblyLoadContextExtensions.cs | 6 +- .../Internals/PluginBuilder.cs | 4 + .../Internals/PluginLoadContext.cs | 6 +- .../PluginBase{T1,T2,T3}.cs | 4 + .../PluginBase{T1,T2}.cs | 4 + .../PluginBase{T}.cs | 4 + .../PluginBuilderExtensions.cs | 4 + .../PluginOrderAttribute.cs | 4 + .../PluginScanner.cs | 4 + .../Extensions.Hosting.SingleInstance.csproj | 6 + .../ResourceMutex.cs | 89 +++- .../Internals/MultiShellContext.cs | 3 + .../Internals/WinFormsThread.cs | 3 + src/Extensions.Hosting.slnx | 1 + .../App.axaml.cs | 1 - .../MainWindow.axaml.cs | 9 +- .../Program.cs | 3 - .../Extensions.Hosting.Maui.Example.csproj | 2 +- .../Platforms/Windows/App.xaml.cs | 2 - src/testconfig.json | 6 +- .../AssemblyLoadingTests.cs | 31 +- .../Extensions.Hosting.Tests.csproj | 1 + .../HostBuilderApplicationExtensionsTests.cs | 30 ++ .../HostBuilderPluginExtensionsTests.cs | 99 ++--- src/tests/Extensions.Hosting.Tests/Plugin.cs | 18 + .../PluginScannerTests.cs | 12 +- .../ReactiveAssemblyLoadingTests.cs | 414 ++++++++++++++++++ .../ReactiveHostedServiceBaseTests.cs | 222 ++++++++++ .../ReactivePluginBaseTests.cs | 101 +++++ .../ReactivePluginBuilderExtensionsTests.cs | 204 +++++++++ .../ReactivePluginOrderAttributeTests.cs | 63 +++ .../ReactivePluginScannerTests.cs | 112 +++++ .../ReactivePluginShimTests.cs | 364 +++++++++++++++ .../ReactiveTestPluginBuilder.cs | 37 ++ .../ResourceMutexTests.cs | 161 +++++++ 45 files changed, 2014 insertions(+), 120 deletions(-) create mode 100644 src/Extensions.Hosting.Plugins.Reactive/Extensions.Hosting.Plugins.Reactive.csproj create mode 100644 src/tests/Extensions.Hosting.Tests/Plugin.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactiveAssemblyLoadingTests.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactiveHostedServiceBaseTests.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactivePluginBaseTests.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactivePluginBuilderExtensionsTests.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactivePluginOrderAttributeTests.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactivePluginScannerTests.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactivePluginShimTests.cs create mode 100644 src/tests/Extensions.Hosting.Tests/ReactiveTestPluginBuilder.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index fb648d3..fe46b68 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,6 +35,7 @@ + diff --git a/src/Extensions.Hosting.Avalonia/Internals/AvaloniaThread.cs b/src/Extensions.Hosting.Avalonia/Internals/AvaloniaThread.cs index df5bb51..193f53b 100644 --- a/src/Extensions.Hosting.Avalonia/Internals/AvaloniaThread.cs +++ b/src/Extensions.Hosting.Avalonia/Internals/AvaloniaThread.cs @@ -6,7 +6,6 @@ using System.Linq; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; using Microsoft.Extensions.DependencyInjection; using ReactiveMarbles.Extensions.Hosting.UiThread; diff --git a/src/Extensions.Hosting.MainUIThread/BaseUiThread.cs b/src/Extensions.Hosting.MainUIThread/BaseUiThread.cs index 7c424a2..b42fc0b 100644 --- a/src/Extensions.Hosting.MainUIThread/BaseUiThread.cs +++ b/src/Extensions.Hosting.MainUIThread/BaseUiThread.cs @@ -61,7 +61,11 @@ protected BaseUiThread(IServiceProvider serviceProvider, bool useDedicatedUiThre IsBackground = true }; +#if NET5_0_OR_GREATER + if (OperatingSystem.IsWindows()) +#else if (Environment.OSVersion.Platform == PlatformID.Win32NT) +#endif { // Set the apartment state for Windows desktop UI frameworks. newUiThread.SetApartmentState(ApartmentState.STA); diff --git a/src/Extensions.Hosting.Plugins.Reactive/Extensions.Hosting.Plugins.Reactive.csproj b/src/Extensions.Hosting.Plugins.Reactive/Extensions.Hosting.Plugins.Reactive.csproj new file mode 100644 index 0000000..c3e9bb2 --- /dev/null +++ b/src/Extensions.Hosting.Plugins.Reactive/Extensions.Hosting.Plugins.Reactive.csproj @@ -0,0 +1,30 @@ + + + + net462;net472;net48;net481;net8.0;net9.0;net10.0;net11.0 + This extension adds reactive plug-in support to generic host based applications. + CP.Extensions.Hosting.Plugins.Reactive + $(DefineConstants);REACTIVE_SHIM + + + + + + + + + + + + + + + + + + + <_Parameter1>Extensions.Hosting.Tests + + + + diff --git a/src/Extensions.Hosting.Plugins/HostBuilderPluginExtensions.cs b/src/Extensions.Hosting.Plugins/HostBuilderPluginExtensions.cs index 57442af..43ba797 100644 --- a/src/Extensions.Hosting.Plugins/HostBuilderPluginExtensions.cs +++ b/src/Extensions.Hosting.Plugins/HostBuilderPluginExtensions.cs @@ -9,9 +9,17 @@ using System.Reflection; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Hosting; +#if REACTIVE_SHIM +using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals; +#else using ReactiveMarbles.Extensions.Hosting.Plugins.Internals; +#endif +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Provides extension methods for configuring and loading plugins in host builder pipelines. /// These extension methods enable plugin discovery and configuration for applications using IHostBuilder @@ -200,7 +208,7 @@ private static void LoadFrameworkAssemblies(IPluginBuilder pluginBuilder, HashSe continue; } - var loadedAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(frameworkAssemblyPath); + var loadedAssembly = AssemblyLoadContext.LoadFromAssemblyPath(frameworkAssemblyPath); _ = scannedAssemblies.Add(loadedAssembly); } } diff --git a/src/Extensions.Hosting.Plugins/HostedServiceBase.cs b/src/Extensions.Hosting.Plugins/HostedServiceBase.cs index f037491..693c34e 100644 --- a/src/Extensions.Hosting.Plugins/HostedServiceBase.cs +++ b/src/Extensions.Hosting.Plugins/HostedServiceBase.cs @@ -8,7 +8,11 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Provides a base implementation for a hosted service with support for resource cleanup, logging, and application lifetime events. /// This base class integrates with the application's lifetime events to manage service startup, diff --git a/src/Extensions.Hosting.Plugins/IPlugin.cs b/src/Extensions.Hosting.Plugins/IPlugin.cs index 0fd1d4b..df38c41 100644 --- a/src/Extensions.Hosting.Plugins/IPlugin.cs +++ b/src/Extensions.Hosting.Plugins/IPlugin.cs @@ -4,7 +4,11 @@ using Microsoft.Extensions.DependencyInjection; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Defines a contract for plug-ins that can configure services and settings for the application host during startup. /// Implementations of this interface can be used to extend or modify the application's dependency diff --git a/src/Extensions.Hosting.Plugins/IPluginBuilder.cs b/src/Extensions.Hosting.Plugins/IPluginBuilder.cs index c2b9365..0b4c53a 100644 --- a/src/Extensions.Hosting.Plugins/IPluginBuilder.cs +++ b/src/Extensions.Hosting.Plugins/IPluginBuilder.cs @@ -7,7 +7,11 @@ using System.Reflection; using Microsoft.Extensions.FileSystemGlobbing; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Defines a contract for configuring and building plugin discovery and loading behavior within an application. /// Implementations of this interface allow customization of plugin and framework assembly scanning, diff --git a/src/Extensions.Hosting.Plugins/Internals/AssemblyDependencyResolver.cs b/src/Extensions.Hosting.Plugins/Internals/AssemblyDependencyResolver.cs index 01c7535..1625e80 100644 --- a/src/Extensions.Hosting.Plugins/Internals/AssemblyDependencyResolver.cs +++ b/src/Extensions.Hosting.Plugins/Internals/AssemblyDependencyResolver.cs @@ -6,7 +6,11 @@ using System.IO; using System.Reflection; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins.Internals; +#endif /// Provides methods for resolving the paths of managed assemblies and unmanaged DLLs relative to a specified plugin directory. /// The absolute path to the root directory containing the plugin and its dependencies. Cannot be null or empty. diff --git a/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContext.cs b/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContext.cs index cf76cfe..ba96e49 100644 --- a/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContext.cs +++ b/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContext.cs @@ -6,7 +6,11 @@ using System.Collections.Generic; using System.Reflection; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins.Internals; +#endif /// Provides functionality for loading and managing assemblies in a custom context, enabling isolation and control over assembly loading behavior. /// AssemblyLoadContext allows applications to load assemblies into isolated contexts, which can be @@ -24,11 +28,19 @@ public class AssemblyLoadContext(string name) /// this property to access the standard assembly loading behavior provided by .NET. public static AssemblyLoadContext Default { get; } = new AssemblyLoadContext("default"); + /// Gets the assemblies that are loaded into the current application domain. + public static IEnumerable Assemblies => AppDomain.CurrentDomain.GetAssemblies(); + /// Gets the name associated with the current instance. public string Name { get; } = name; - /// Gets the assemblies that are loaded into the current application domain. - public IEnumerable Assemblies => AppDomain.CurrentDomain.GetAssemblies(); + /// Loads an assembly from the specified file path. + /// The assembly is loaded into the load-from context. If the assembly has already been loaded, + /// this method may return a reference to the existing assembly. This method does not resolve dependencies + /// automatically; dependent assemblies must be available to the loader. + /// The path to the assembly file to load. The path must be a valid file system path to a managed assembly file. + /// The loaded assembly represented by the specified file path. + public static Assembly LoadFromAssemblyPath(string assemblyPath) => Assembly.LoadFrom(assemblyPath); /// Loads an assembly given its display name. /// This method loads the assembly into the current load context. If the assembly has already @@ -47,27 +59,19 @@ public Assembly LoadFromAssemblyName(AssemblyName assemblyName) return Load(assemblyName); } - /// Loads an assembly from the specified file path. - /// The assembly is loaded into the load-from context. If the assembly has already been loaded, - /// this method may return a reference to the existing assembly. This method does not resolve dependencies - /// automatically; dependent assemblies must be available to the loader. - /// The path to the assembly file to load. The path must be a valid file system path to a managed assembly file. - /// The loaded assembly represented by the specified file path. - public Assembly LoadFromAssemblyPath(string assemblyPath) => Assembly.LoadFrom(assemblyPath); - - /// Loads the assembly with the specified name. - /// Override this method to implement custom assembly loading logic in a derived class. - /// The name of the assembly to load. Cannot be null. - /// The loaded assembly, or null if the assembly cannot be found. - protected virtual Assembly Load(AssemblyName assemblyName) => null!; - /// Loads an unmanaged dynamic-link library (DLL) from the specified absolute path. /// This method is intended to be called by derived classes to provide custom logic for loading /// unmanaged libraries. The caller is responsible for ensuring that the specified path points to a valid and /// compatible DLL. /// The absolute path to the unmanaged DLL to load. Cannot be null or empty. /// A handle to the loaded unmanaged DLL. Returns if the library could not be loaded. - protected IntPtr LoadUnmanagedDllFromPath(string dllPath) => IntPtr.Zero; + protected static IntPtr LoadUnmanagedDllFromPath(string dllPath) => IntPtr.Zero; + + /// Loads the assembly with the specified name. + /// Override this method to implement custom assembly loading logic in a derived class. + /// The name of the assembly to load. Cannot be null. + /// The loaded assembly, or null if the assembly cannot be found. + protected virtual Assembly Load(AssemblyName assemblyName) => null!; /// Loads the specified unmanaged DLL into the process address space. /// Override this method to provide custom logic for loading unmanaged libraries when resolving diff --git a/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContextExtensions.cs b/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContextExtensions.cs index 40604bb..24eb9ac 100644 --- a/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContextExtensions.cs +++ b/src/Extensions.Hosting.Plugins/Internals/AssemblyLoadContextExtensions.cs @@ -4,7 +4,11 @@ using System.Reflection; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins.Internals; +#endif /// Provides extension methods for the AssemblyLoadContext class. /// This static class contains methods that extend the functionality of AssemblyLoadContext, enabling @@ -32,7 +36,7 @@ public bool TryGetAssembly(AssemblyName assemblyName, out Assembly? foundAssembl return false; } - foreach (var assembly in assemblyLoadContext.Assemblies) + foreach (var assembly in AssemblyLoadContext.Assemblies) { var name = assembly.GetName().Name; if (name is null) diff --git a/src/Extensions.Hosting.Plugins/Internals/PluginBuilder.cs b/src/Extensions.Hosting.Plugins/Internals/PluginBuilder.cs index b328e16..49ba00d 100644 --- a/src/Extensions.Hosting.Plugins/Internals/PluginBuilder.cs +++ b/src/Extensions.Hosting.Plugins/Internals/PluginBuilder.cs @@ -7,7 +7,11 @@ using System.Reflection; using Microsoft.Extensions.FileSystemGlobbing; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins.Internals; +#endif /// Provides configuration and matching logic for plugin discovery and loading within the application. /// The PluginBuilder class exposes properties and delegates that allow customization of plugin scanning, diff --git a/src/Extensions.Hosting.Plugins/Internals/PluginLoadContext.cs b/src/Extensions.Hosting.Plugins/Internals/PluginLoadContext.cs index 7437b10..8bf2a18 100644 --- a/src/Extensions.Hosting.Plugins/Internals/PluginLoadContext.cs +++ b/src/Extensions.Hosting.Plugins/Internals/PluginLoadContext.cs @@ -5,7 +5,11 @@ using System; using System.Reflection; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins.Internals; +#endif /// Provides an isolated assembly load context for loading plugins from a specified directory. /// PluginLoadContext enables loading and resolving assemblies and unmanaged libraries for plugins @@ -45,6 +49,6 @@ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) private Assembly? LoadAssemblyFromResolvedPath(AssemblyName assemblyName) { var assemblyPath = ResolveAssemblyPath(assemblyName); - return assemblyPath is null ? null : LoadFromAssemblyPath(assemblyPath); + return assemblyPath is null ? null : AssemblyLoadContext.LoadFromAssemblyPath(assemblyPath); } } diff --git a/src/Extensions.Hosting.Plugins/PluginBase{T1,T2,T3}.cs b/src/Extensions.Hosting.Plugins/PluginBase{T1,T2,T3}.cs index 19f3ff1..9f6cfce 100644 --- a/src/Extensions.Hosting.Plugins/PluginBase{T1,T2,T3}.cs +++ b/src/Extensions.Hosting.Plugins/PluginBase{T1,T2,T3}.cs @@ -5,7 +5,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Provides a base implementation for plugins that register three hosted services with the application host. /// This class is intended to be used as a base for plugins that require multiple hosted services to be diff --git a/src/Extensions.Hosting.Plugins/PluginBase{T1,T2}.cs b/src/Extensions.Hosting.Plugins/PluginBase{T1,T2}.cs index 7b86b6f..ac796bf 100644 --- a/src/Extensions.Hosting.Plugins/PluginBase{T1,T2}.cs +++ b/src/Extensions.Hosting.Plugins/PluginBase{T1,T2}.cs @@ -5,7 +5,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Provides a base implementation for plugins that register two hosted services with a dependency injection container. /// This class is intended to be used as a base for plugins that require multiple hosted services to be diff --git a/src/Extensions.Hosting.Plugins/PluginBase{T}.cs b/src/Extensions.Hosting.Plugins/PluginBase{T}.cs index 65a58c8..f7ea479 100644 --- a/src/Extensions.Hosting.Plugins/PluginBase{T}.cs +++ b/src/Extensions.Hosting.Plugins/PluginBase{T}.cs @@ -5,7 +5,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Provides a base implementation for plugins that register a hosted service of the specified type with the application's dependency injection container. /// The type of the hosted service to register. Must implement . diff --git a/src/Extensions.Hosting.Plugins/PluginBuilderExtensions.cs b/src/Extensions.Hosting.Plugins/PluginBuilderExtensions.cs index 229029e..173a988 100644 --- a/src/Extensions.Hosting.Plugins/PluginBuilderExtensions.cs +++ b/src/Extensions.Hosting.Plugins/PluginBuilderExtensions.cs @@ -5,7 +5,11 @@ using System; using System.IO; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Provides extension methods for configuring plug-in and framework assembly scanning behavior on an instance. /// These extension methods allow customization of directory scanning, inclusion and exclusion patterns diff --git a/src/Extensions.Hosting.Plugins/PluginOrderAttribute.cs b/src/Extensions.Hosting.Plugins/PluginOrderAttribute.cs index 8176536..608ae84 100644 --- a/src/Extensions.Hosting.Plugins/PluginOrderAttribute.cs +++ b/src/Extensions.Hosting.Plugins/PluginOrderAttribute.cs @@ -4,7 +4,11 @@ using System; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Specifies the initialization order for a plug-in class. /// Apply this attribute to a plug-in class to control the sequence in which plug-ins are initialized. diff --git a/src/Extensions.Hosting.Plugins/PluginScanner.cs b/src/Extensions.Hosting.Plugins/PluginScanner.cs index 5064fd3..d90d523 100644 --- a/src/Extensions.Hosting.Plugins/PluginScanner.cs +++ b/src/Extensions.Hosting.Plugins/PluginScanner.cs @@ -6,7 +6,11 @@ using System.Collections.Generic; using System.Reflection; +#if REACTIVE_SHIM +namespace ReactiveMarbles.Extensions.Hosting.Reactive.Plugins; +#else namespace ReactiveMarbles.Extensions.Hosting.Plugins; +#endif /// Provides methods for discovering and instantiating plugin implementations from assemblies using naming conventions or type scanning. /// The PluginScanner class is intended for use in scenarios where plugins implementing the IPlugin diff --git a/src/Extensions.Hosting.SingleInstance/Extensions.Hosting.SingleInstance.csproj b/src/Extensions.Hosting.SingleInstance/Extensions.Hosting.SingleInstance.csproj index 759b77a..324ea99 100644 --- a/src/Extensions.Hosting.SingleInstance/Extensions.Hosting.SingleInstance.csproj +++ b/src/Extensions.Hosting.SingleInstance/Extensions.Hosting.SingleInstance.csproj @@ -10,4 +10,10 @@ + + + <_Parameter1>Extensions.Hosting.Tests + + + diff --git a/src/Extensions.Hosting.SingleInstance/ResourceMutex.cs b/src/Extensions.Hosting.SingleInstance/ResourceMutex.cs index 53b2819..a90b480 100644 --- a/src/Extensions.Hosting.SingleInstance/ResourceMutex.cs +++ b/src/Extensions.Hosting.SingleInstance/ResourceMutex.cs @@ -70,6 +70,18 @@ public sealed class ResourceMutex : IDisposable /// Stores the resource name value. private readonly string _resourceName; + /// Stores the mutex factory value. + private readonly Func _createMutex; + + /// Stores the mutex release action value. + private readonly Action _releaseMutex; + + /// Stores the optional before release action value. + private readonly Action? _beforeReleaseMutex; + + /// Stores the owner thread join timeout value. + private readonly TimeSpan _ownerThreadJoinTimeout; + /// Stores the lock acquired signal value. private readonly ManualResetEventSlim _lockAcquiredSignal = new(initialState: false); @@ -82,15 +94,39 @@ public sealed class ResourceMutex : IDisposable /// Stores the owner thread value. private Thread? _ownerThread; - /// Initializes a new instance of the class with the specified logger, mutex identifier, and optional. resource name. + /// Initializes a new instance of the class with testable mutex operations. /// The logger instance used to record diagnostic and operational messages for the mutex. /// The unique identifier for the mutex. Used to distinguish this mutex from others. /// The name of the resource associated with the mutex. If null, the mutex identifier is used as the resource name. - private ResourceMutex(ILogger logger, string mutexId, string? resourceName = null) + /// The operation used to create the underlying mutex. + /// The operation used to release the underlying mutex. + /// The amount of time to wait for the owner thread to exit during disposal. + /// An optional operation to run on the owner thread before releasing the mutex. + internal ResourceMutex( + ILogger logger, + string mutexId, + string? resourceName, + Func createMutex, + Action releaseMutex, + TimeSpan ownerThreadJoinTimeout, + Action? beforeReleaseMutex = null) { _logger = logger; _mutexId = mutexId; _resourceName = resourceName ?? mutexId; + _createMutex = createMutex; + _releaseMutex = releaseMutex; + _ownerThreadJoinTimeout = ownerThreadJoinTimeout; + _beforeReleaseMutex = beforeReleaseMutex; + } + + /// Initializes a new instance of the class with the specified logger, mutex identifier, and optional. resource name. + /// The logger instance used to record diagnostic and operational messages for the mutex. + /// The unique identifier for the mutex. Used to distinguish this mutex from others. + /// The name of the resource associated with the mutex. If null, the mutex identifier is used as the resource name. + private ResourceMutex(ILogger logger, string mutexId, string? resourceName = null) + : this(logger, mutexId, resourceName, CreateMutex, static mutex => mutex.ReleaseMutex(), TimeSpan.FromSeconds(5), null) + { } /// Gets a value indicating whether the object is locked. @@ -159,7 +195,7 @@ public void Dispose() // Signal the owning thread to release the mutex, then wait for it to finish. _releaseSignal.Set(); - if (_ownerThread?.Join(TimeSpan.FromSeconds(5)) == false) + if (_ownerThread?.Join(_ownerThreadJoinTimeout) == false) { _mutexOwnerReleaseTimedOut(_logger, _mutexId, _resourceName, null); } @@ -168,6 +204,27 @@ public void Dispose() _lockAcquiredSignal.Dispose(); } + /// Creates the underlying named mutex. + /// The fully qualified mutex identifier. + /// The created mutex and a value indicating whether it was newly created. + private static (Mutex Mutex, bool CreatedNew) CreateMutex(string mutexId) + { +#if NET462 + // Added Mutex Security, hopefully this prevents the UnauthorizedAccessException more gracefully + var sid = new SecurityIdentifier(WellKnownSidType.WorldSid, null); + var mutexSecurity = new MutexSecurity(); + mutexSecurity.AddAccessRule(new(sid, MutexRights.FullControl, AccessControlType.Allow)); + mutexSecurity.AddAccessRule(new(sid, MutexRights.ChangePermissions, AccessControlType.Deny)); + mutexSecurity.AddAccessRule(new(sid, MutexRights.Delete, AccessControlType.Deny)); + + // 1) Create Mutex + return (new Mutex(true, mutexId, out var createdNew, mutexSecurity), createdNew); +#else + // 1) Create Mutex + return (new Mutex(true, mutexId, out var createdNew), createdNew); +#endif + } + /// /// Runs on the dedicated owner thread: acquires the mutex, signals the caller, waits for the dispose /// signal, then releases the mutex - all on the same thread to satisfy @@ -182,20 +239,9 @@ private void AcquireAndHoldMutex() // check whether there's an local instance running already, but use local so this works in a multi-user environment try { -#if NET462 - // Added Mutex Security, hopefully this prevents the UnauthorizedAccessException more gracefully - var sid = new SecurityIdentifier(WellKnownSidType.WorldSid, null); - var mutexSecurity = new MutexSecurity(); - mutexSecurity.AddAccessRule(new(sid, MutexRights.FullControl, AccessControlType.Allow)); - mutexSecurity.AddAccessRule(new(sid, MutexRights.ChangePermissions, AccessControlType.Deny)); - mutexSecurity.AddAccessRule(new(sid, MutexRights.Delete, AccessControlType.Deny)); - - // 1) Create Mutex - applicationMutex = new(true, _mutexId, out var createdNew, mutexSecurity); -#else - // 1) Create Mutex - applicationMutex = new(true, _mutexId, out var createdNew); -#endif + var mutex = _createMutex(_mutexId); + applicationMutex = mutex.Mutex; + var createdNew = mutex.CreatedNew; // 2) if the mutex wasn't created new get the right to it, this returns false if it's already locked if (!createdNew) @@ -251,22 +297,25 @@ private void AcquireAndHoldMutex() // Hold until Dispose() signals that we should release. _releaseSignal.Wait(); + _beforeReleaseMutex?.Invoke(); // Release on this thread - the same thread that acquired the mutex - to satisfy Mutex thread-affinity. try { if (IsLocked && applicationMutex is not null) { - applicationMutex.ReleaseMutex(); + _releaseMutex(applicationMutex); IsLocked = false; _mutexReleased(_logger, _mutexId, _resourceName, null); } - - applicationMutex?.Dispose(); } catch (Exception ex) { _mutexReleaseFailed(_logger, _mutexId, _resourceName, ex); } + finally + { + applicationMutex?.Dispose(); + } } } diff --git a/src/Extensions.Hosting.WinForms/Internals/MultiShellContext.cs b/src/Extensions.Hosting.WinForms/Internals/MultiShellContext.cs index f20d33f..e5d6baf 100644 --- a/src/Extensions.Hosting.WinForms/Internals/MultiShellContext.cs +++ b/src/Extensions.Hosting.WinForms/Internals/MultiShellContext.cs @@ -40,6 +40,9 @@ public MultiShellContext(params Form[] forms) /// A FormClosedEventArgs object that contains the event data. private void OnFormClosed(object? s, FormClosedEventArgs args) { + _ = s; + _ = args; + // When we have closed the last of the "starting" forms, end the program. if (Interlocked.Decrement(ref _openForms) != 0) { diff --git a/src/Extensions.Hosting.WinForms/Internals/WinFormsThread.cs b/src/Extensions.Hosting.WinForms/Internals/WinFormsThread.cs index 6a65aa0..269cde4 100644 --- a/src/Extensions.Hosting.WinForms/Internals/WinFormsThread.cs +++ b/src/Extensions.Hosting.WinForms/Internals/WinFormsThread.cs @@ -79,6 +79,9 @@ protected override void UiThreadStart() /// An object that contains the event data associated with the application exit event. private void OnApplicationExit(object? sender, EventArgs eventArgs) { + _ = sender; + _ = eventArgs; + Application.ApplicationExit -= OnApplicationExit; HandleApplicationExit(); diff --git a/src/Extensions.Hosting.slnx b/src/Extensions.Hosting.slnx index c4f0e88..aecfb10 100644 --- a/src/Extensions.Hosting.slnx +++ b/src/Extensions.Hosting.slnx @@ -31,6 +31,7 @@ + diff --git a/src/examples/Extensions.Hosting.Avalonia.Example/App.axaml.cs b/src/examples/Extensions.Hosting.Avalonia.Example/App.axaml.cs index fb07a96..021f71f 100644 --- a/src/examples/Extensions.Hosting.Avalonia.Example/App.axaml.cs +++ b/src/examples/Extensions.Hosting.Avalonia.Example/App.axaml.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; namespace Extensions.Hosting.Avalonia.Example; diff --git a/src/examples/Extensions.Hosting.Avalonia.Example/MainWindow.axaml.cs b/src/examples/Extensions.Hosting.Avalonia.Example/MainWindow.axaml.cs index 1249178..64d63a0 100644 --- a/src/examples/Extensions.Hosting.Avalonia.Example/MainWindow.axaml.cs +++ b/src/examples/Extensions.Hosting.Avalonia.Example/MainWindow.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Interactivity; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using ReactiveMarbles.Extensions.Hosting.Avalonia; namespace Extensions.Hosting.Avalonia.Example; @@ -22,6 +23,12 @@ public partial class MainWindow : Window, IAvaloniaShell /// Stores the logger value. private readonly ILogger _logger; + /// Initializes a new instance of the class for the Avalonia runtime loader. + public MainWindow() + : this(NullLogger.Instance) + { + } + /// Initializes a new instance of the class and sets up the user interface and event handlers. /// This constructor initializes the UI components and attaches the Click event handler for the /// Exit button, enabling the application to close when the button is clicked. @@ -32,7 +39,7 @@ public MainWindow(ILogger logger) _logger = logger; // Attach event handlers - var exitButton = this.FindControl