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("ExitButton");
+ var exitButton = this.FindControl(nameof(ExitButton));
exitButton?.Click += ExitButton_Click;
}
diff --git a/src/examples/Extensions.Hosting.Avalonia.Example/Program.cs b/src/examples/Extensions.Hosting.Avalonia.Example/Program.cs
index 55cd0e9..d4f0bae 100644
--- a/src/examples/Extensions.Hosting.Avalonia.Example/Program.cs
+++ b/src/examples/Extensions.Hosting.Avalonia.Example/Program.cs
@@ -3,7 +3,6 @@
// See the LICENSE file in the project root for full license information.
using System;
-using Avalonia;
using Microsoft.Extensions.Hosting;
using ReactiveMarbles.Extensions.Hosting.Avalonia;
@@ -40,8 +39,6 @@ public static int Main(string[] args)
.UseConsoleLifetime()
.Build();
- Console.WriteLine("Run!");
-
host.Run();
return 0;
diff --git a/src/examples/Extensions.Hosting.Maui.Example/Extensions.Hosting.Maui.Example.csproj b/src/examples/Extensions.Hosting.Maui.Example/Extensions.Hosting.Maui.Example.csproj
index d52ff40..9feddbb 100644
--- a/src/examples/Extensions.Hosting.Maui.Example/Extensions.Hosting.Maui.Example.csproj
+++ b/src/examples/Extensions.Hosting.Maui.Example/Extensions.Hosting.Maui.Example.csproj
@@ -37,7 +37,7 @@
24.0
10.0.17763.0
10.0.17763.0
- NU1605;SA1633;SA1600;SA1601;SA1027;SA1137;SX1309;CA1805;CA1400;RCS1102;SA1400;SA1508;SA1512;SA1642;SX1101
+ $(NoWarn);NU1605;SA1633;SA1600;SA1601;SA1027;SA1137;SX1309;CA1805;CA1400;RCS1102;SA1400;SA1508;SA1512;SA1642;SX1101
false
diff --git a/src/examples/Extensions.Hosting.Maui.Example/Platforms/Windows/App.xaml.cs b/src/examples/Extensions.Hosting.Maui.Example/Platforms/Windows/App.xaml.cs
index 29fbe3a..c4012ec 100644
--- a/src/examples/Extensions.Hosting.Maui.Example/Platforms/Windows/App.xaml.cs
+++ b/src/examples/Extensions.Hosting.Maui.Example/Platforms/Windows/App.xaml.cs
@@ -2,8 +2,6 @@
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
-using Microsoft.UI.Xaml;
-
namespace Extensions.Hosting.Maui.Example.WinUI;
/// Provides application-specific behavior to supplement the default Application class.
diff --git a/src/testconfig.json b/src/testconfig.json
index 0771de1..b20cf2b 100644
--- a/src/testconfig.json
+++ b/src/testconfig.json
@@ -12,11 +12,13 @@
"skipAutoProperties": true,
"modulePaths": {
"include": [
- "Extensions.Hosting\\..*"
+ "^Extensions\\.Hosting\\..*$"
],
"exclude": [
".*Tests.*",
- ".*TestRunner.*"
+ ".*TestRunner.*",
+ "^Extensions\\.Hosting\\..*\\.Example$",
+ "^Extensions\\.Logging\\..*"
]
}
}
diff --git a/src/tests/Extensions.Hosting.Tests/AssemblyLoadingTests.cs b/src/tests/Extensions.Hosting.Tests/AssemblyLoadingTests.cs
index 12af9b7..1751fe7 100644
--- a/src/tests/Extensions.Hosting.Tests/AssemblyLoadingTests.cs
+++ b/src/tests/Extensions.Hosting.Tests/AssemblyLoadingTests.cs
@@ -21,7 +21,7 @@ public async Task ResolveAssemblyToPath_WithExistingAssembly_ReturnsPath()
var tempDirectory = CreateTemporaryDirectory();
try
{
- var pluginPath = Path.Combine(tempDirectory, "Plugin.dll");
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
var dependencyPath = Path.Combine(tempDirectory, "Dependency.dll");
await File.WriteAllTextAsync(pluginPath, string.Empty);
await File.WriteAllTextAsync(dependencyPath, string.Empty);
@@ -45,7 +45,7 @@ public async Task ResolveAssemblyToPath_WithMissingAssembly_ReturnsNull()
var tempDirectory = CreateTemporaryDirectory();
try
{
- var pluginPath = Path.Combine(tempDirectory, "Plugin.dll");
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
await File.WriteAllTextAsync(pluginPath, string.Empty);
var resolver = new PluginAssemblyDependencyResolver(pluginPath);
@@ -64,7 +64,7 @@ public async Task ResolveAssemblyToPath_WithMissingAssembly_ReturnsNull()
[Test]
public async Task ResolveAssemblyToPath_WithNullAssemblyName_ThrowsArgumentNullException()
{
- var resolver = new PluginAssemblyDependencyResolver("Plugin.dll");
+ var resolver = new PluginAssemblyDependencyResolver($"{nameof(Plugin)}.dll");
AssemblyName? assemblyName = null;
var act = () => resolver.ResolveAssemblyToPath(assemblyName!);
@@ -80,7 +80,7 @@ public async Task ResolveUnmanagedDllToPath_WithImplicitExtension_ReturnsPath()
var tempDirectory = CreateTemporaryDirectory();
try
{
- var pluginPath = Path.Combine(tempDirectory, "Plugin.dll");
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
var dependencyPath = Path.Combine(tempDirectory, "native.dll");
await File.WriteAllTextAsync(pluginPath, string.Empty);
await File.WriteAllTextAsync(dependencyPath, string.Empty);
@@ -104,7 +104,7 @@ public async Task ResolveUnmanagedDllToPath_WithExplicitExtension_ReturnsPath()
var tempDirectory = CreateTemporaryDirectory();
try
{
- var pluginPath = Path.Combine(tempDirectory, "Plugin.dll");
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
var dependencyPath = Path.Combine(tempDirectory, "native.custom");
await File.WriteAllTextAsync(pluginPath, string.Empty);
await File.WriteAllTextAsync(dependencyPath, string.Empty);
@@ -125,7 +125,7 @@ public async Task ResolveUnmanagedDllToPath_WithExplicitExtension_ReturnsPath()
[Test]
public async Task ResolveUnmanagedDllToPath_WithEmptyName_ThrowsArgumentException()
{
- var resolver = new PluginAssemblyDependencyResolver("Plugin.dll");
+ var resolver = new PluginAssemblyDependencyResolver($"{nameof(Plugin)}.dll");
var act = () => resolver.ResolveUnmanagedDllToPath(string.Empty);
@@ -140,7 +140,7 @@ public async Task AssemblyLoadContext_Default_ExposesNameAndAssemblies()
var context = PluginAssemblyLoadContext.Default;
await Assert.That(context.Name).IsEqualTo("default");
- await Assert.That(context.Assemblies.Any()).IsTrue();
+ await Assert.That(PluginAssemblyLoadContext.Assemblies.Any()).IsTrue();
}
/// Verifies that loading from a null assembly name throws.
@@ -186,10 +186,9 @@ public async Task LoadFromAssemblyName_WithDerivedLoad_ReturnsAssembly()
[Test]
public async Task LoadFromAssemblyPath_WithExistingAssemblyPath_ReturnsAssembly()
{
- var context = new PluginAssemblyLoadContext("test");
var expectedAssembly = typeof(AssemblyLoadingTests).Assembly;
- var assembly = context.LoadFromAssemblyPath(expectedAssembly.Location);
+ var assembly = PluginAssemblyLoadContext.LoadFromAssemblyPath(expectedAssembly.Location);
await Assert.That(assembly.GetName().Name).IsEqualTo(expectedAssembly.GetName().Name);
}
@@ -201,7 +200,7 @@ public async Task LoadUnmanagedDllHelpers_ByDefault_ReturnZero()
{
var context = new TestAssemblyLoadContext("test", typeof(AssemblyLoadingTests).Assembly);
- await Assert.That(context.LoadNativeFromPath("native.dll")).IsEqualTo(IntPtr.Zero);
+ await Assert.That(TestAssemblyLoadContext.LoadNativeFromPath("native.dll")).IsEqualTo(IntPtr.Zero);
await Assert.That(context.LoadNativeByName("native")).IsEqualTo(IntPtr.Zero);
}
@@ -239,11 +238,11 @@ public async Task PluginLoadContext_ResolveAssemblyPath_WithExistingDependency_R
var tempDirectory = CreateTemporaryDirectory();
try
{
- var pluginPath = Path.Combine(tempDirectory, "Plugin.dll");
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
var dependencyPath = Path.Combine(tempDirectory, "Dependency.dll");
await File.WriteAllTextAsync(pluginPath, string.Empty);
await File.WriteAllTextAsync(dependencyPath, string.Empty);
- var context = new PluginLoadContext(pluginPath, "Plugin");
+ var context = new PluginLoadContext(pluginPath, nameof(Plugin));
var resolvedPath = context.ResolveAssemblyPath(new AssemblyName("Dependency"));
@@ -261,7 +260,7 @@ public async Task PluginLoadContext_ResolveAssemblyPath_WithExistingDependency_R
public async Task PluginLoadContext_LoadFromAssemblyName_WithAlreadyLoadedAssembly_ReturnsAssembly()
{
var expectedAssembly = typeof(AssemblyLoadingTests).Assembly;
- var context = new PluginLoadContext(expectedAssembly.Location, "Plugin");
+ var context = new PluginLoadContext(expectedAssembly.Location, nameof(Plugin));
var assembly = context.LoadFromAssemblyName(expectedAssembly.GetName());
@@ -287,7 +286,7 @@ public async Task PluginLoadContext_LoadFromAssemblyName_WithPluginLocalAssembly
[Test]
public async Task PluginLoadContext_LoadFromAssemblyName_WithMissingAssembly_ReturnsNull()
{
- var context = new PluginLoadContext(typeof(AssemblyLoadingTests).Assembly.Location, "Plugin");
+ var context = new PluginLoadContext(typeof(AssemblyLoadingTests).Assembly.Location, nameof(Plugin));
Assembly? assembly = context.LoadFromAssemblyName(new AssemblyName("Missing.Plugin.Assembly"));
@@ -299,7 +298,7 @@ public async Task PluginLoadContext_LoadFromAssemblyName_WithMissingAssembly_Ret
[Test]
public async Task PluginLoadContext_LoadUnmanagedDll_WithMissingLibrary_ReturnsZero()
{
- var context = new PluginLoadContext(typeof(AssemblyLoadingTests).Assembly.Location, "Plugin");
+ var context = new PluginLoadContext(typeof(AssemblyLoadingTests).Assembly.Location, nameof(Plugin));
var method = typeof(PluginLoadContext).GetMethod("LoadUnmanagedDll", BindingFlags.Instance | BindingFlags.NonPublic);
var result = (IntPtr)method!.Invoke(context, ["missing"])!;
@@ -347,7 +346,7 @@ private sealed class TestAssemblyLoadContext(string name, Assembly assembly) : P
/// Calls the protected native load-from-path helper.
/// The native library path.
/// The native library handle.
- public IntPtr LoadNativeFromPath(string path) => LoadUnmanagedDllFromPath(path);
+ public static IntPtr LoadNativeFromPath(string path) => LoadUnmanagedDllFromPath(path);
/// Calls the protected native load-by-name helper.
/// The native library name.
diff --git a/src/tests/Extensions.Hosting.Tests/Extensions.Hosting.Tests.csproj b/src/tests/Extensions.Hosting.Tests/Extensions.Hosting.Tests.csproj
index 9372f1d..e7cc2d4 100644
--- a/src/tests/Extensions.Hosting.Tests/Extensions.Hosting.Tests.csproj
+++ b/src/tests/Extensions.Hosting.Tests/Extensions.Hosting.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/tests/Extensions.Hosting.Tests/HostBuilderApplicationExtensionsTests.cs b/src/tests/Extensions.Hosting.Tests/HostBuilderApplicationExtensionsTests.cs
index 301033a..e9d63fd 100644
--- a/src/tests/Extensions.Hosting.Tests/HostBuilderApplicationExtensionsTests.cs
+++ b/src/tests/Extensions.Hosting.Tests/HostBuilderApplicationExtensionsTests.cs
@@ -43,6 +43,21 @@ public async Task ConfigureSingleInstance_WithMutexId_ReturnsHostBuilder()
await Assert.That(result).IsEqualTo(hostBuilder);
}
+ /// Verifies that ConfigureSingleInstance with mutexId stores the mutex identifier on IHostBuilder.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigureSingleInstance_WithMutexId_RegistersConfiguredMutexId()
+ {
+ var hostBuilder = Host.CreateDefaultBuilder();
+ var mutexId = "test-mutex-" + Guid.NewGuid().ToString("N");
+
+ _ = hostBuilder.ConfigureSingleInstance(mutexId);
+
+ using var host = hostBuilder.Build();
+ var mutexBuilder = host.Services.GetRequiredService();
+ await Assert.That(mutexBuilder.MutexId).IsEqualTo(mutexId);
+ }
+
/// Verifies that ConfigureSingleInstance with IHostApplicationBuilder returns the same builder and registers the mutex builder.
/// A task that represents the asynchronous test operation.
[Test]
@@ -96,6 +111,21 @@ public async Task ConfigureSingleInstance_WithConfigureAction_ReturnsHostBuilder
await Assert.That(result).IsEqualTo(hostBuilder);
}
+ /// Verifies that ConfigureSingleInstance accepts a null configure action for IHostBuilder.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigureSingleInstance_IHostBuilder_WithNullConfigureAction_RegistersMutexBuilder()
+ {
+ Action? configureAction = null;
+ var hostBuilder = Host.CreateDefaultBuilder();
+
+ _ = hostBuilder.ConfigureSingleInstance(configureAction!);
+
+ using var host = hostBuilder.Build();
+ var mutexBuilder = host.Services.GetService();
+ await Assert.That(mutexBuilder).IsNotNull();
+ }
+
/// Verifies that ConfigureSingleInstance configure action is invoked.
/// A task that represents the asynchronous test operation.
[Test]
diff --git a/src/tests/Extensions.Hosting.Tests/HostBuilderPluginExtensionsTests.cs b/src/tests/Extensions.Hosting.Tests/HostBuilderPluginExtensionsTests.cs
index 3cdd6d1..0a85c88 100644
--- a/src/tests/Extensions.Hosting.Tests/HostBuilderPluginExtensionsTests.cs
+++ b/src/tests/Extensions.Hosting.Tests/HostBuilderPluginExtensionsTests.cs
@@ -174,25 +174,29 @@ public async Task ConfigurePlugins_IHostBuilder_WithAlreadyLoadedPluginFile_Igno
public async Task ConfigurePlugins_IHostBuilder_WithUnloadedFrameworkAssembly_LoadsAndScansAssembly()
{
var configuredPlugins = new List();
- var assemblyPath = GetUnloadedAssemblyPath();
- var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
- var hostBuilder = Host.CreateDefaultBuilder();
-
- _ = hostBuilder.ConfigurePlugins(builder =>
+ var tempDirectory = CreateTemporaryDirectory();
+ try
{
- ArgumentNullException.ThrowIfNull(builder);
- builder.FrameworkDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
- _ = builder.FrameworkMatcher.AddInclude(Path.GetFileName(assemblyPath));
- builder.AssemblyScanFunc = assembly =>
- assembly.GetName().Name == assemblyName
- ? [new EarlierRecordingPlugin(configuredPlugins)]
- : [];
- });
+ var assemblyPath = CopyCurrentAssemblyToTemporaryPlugin(tempDirectory);
+ var hostBuilder = Host.CreateDefaultBuilder();
- using var host = hostBuilder.Build();
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.FrameworkDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
+ _ = builder.FrameworkMatcher.AddInclude(Path.GetFileName(assemblyPath));
+ builder.AssemblyScanFunc = _ => [new EarlierRecordingPlugin(configuredPlugins)];
+ });
- await Assert.That(configuredPlugins.Count).IsEqualTo(1);
- await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierRecordingPlugin.Name);
+ using var host = hostBuilder.Build();
+
+ await Assert.That(configuredPlugins.Count).IsEqualTo(1);
+ await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierRecordingPlugin.Name);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
}
/// Verifies that plugin assemblies not yet loaded in the default context are loaded and scanned.
@@ -201,25 +205,29 @@ public async Task ConfigurePlugins_IHostBuilder_WithUnloadedFrameworkAssembly_Lo
public async Task ConfigurePlugins_IHostBuilder_WithUnloadedPluginAssembly_LoadsAndScansAssembly()
{
var configuredPlugins = new List();
- var assemblyPath = GetUnloadedAssemblyPath();
- var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
- var hostBuilder = Host.CreateDefaultBuilder();
-
- _ = hostBuilder.ConfigurePlugins(builder =>
+ var tempDirectory = CreateTemporaryDirectory();
+ try
{
- ArgumentNullException.ThrowIfNull(builder);
- builder.PluginDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
- _ = builder.PluginMatcher.AddInclude(Path.GetFileName(assemblyPath));
- builder.AssemblyScanFunc = assembly =>
- assembly.GetName().Name == assemblyName
- ? [new EarlierRecordingPlugin(configuredPlugins)]
- : [];
- });
+ var assemblyPath = CopyCurrentAssemblyToTemporaryPlugin(tempDirectory);
+ var hostBuilder = Host.CreateDefaultBuilder();
- using var host = hostBuilder.Build();
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.PluginDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
+ _ = builder.PluginMatcher.AddInclude(Path.GetFileName(assemblyPath));
+ builder.AssemblyScanFunc = _ => [new EarlierRecordingPlugin(configuredPlugins)];
+ });
- await Assert.That(configuredPlugins.Count).IsEqualTo(1);
- await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierRecordingPlugin.Name);
+ using var host = hostBuilder.Build();
+
+ await Assert.That(configuredPlugins.Count).IsEqualTo(1);
+ await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierRecordingPlugin.Name);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
}
/// Verifies that required plugin configuration fails when no plugins are discovered.
@@ -269,27 +277,14 @@ private static string CreateTemporaryDirectory()
return tempDirectory;
}
- /// Finds a managed assembly in the test output that has not yet been loaded.
- /// The path to an unloaded managed assembly.
- private static string GetUnloadedAssemblyPath()
+ /// Copies the current test assembly to a unique plugin assembly path.
+ /// The temporary directory to copy into.
+ /// The copied assembly path.
+ private static string CopyCurrentAssemblyToTemporaryPlugin(string tempDirectory)
{
- var assemblyDirectory = Path.GetDirectoryName(typeof(HostBuilderPluginExtensionsTests).Assembly.Location)!;
- var loadedAssemblyNames = AppDomain.CurrentDomain.GetAssemblies()
- .Select(assembly => assembly.GetName().Name)
- .Where(name => name is not null)
- .ToHashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (var assemblyPath in Directory.EnumerateFiles(assemblyDirectory, "*.dll"))
- {
- var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
- if (!loadedAssemblyNames.Contains(assemblyName) &&
- !assemblyName.StartsWith("Extensions.Hosting", StringComparison.OrdinalIgnoreCase))
- {
- return assemblyPath;
- }
- }
-
- throw new InvalidOperationException("No unloaded assembly was available in the test output directory.");
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.{Guid.NewGuid():N}.dll");
+ File.Copy(typeof(HostBuilderPluginExtensionsTests).Assembly.Location, pluginPath);
+ return pluginPath;
}
/// Records configuration with an earlier plugin order.
diff --git a/src/tests/Extensions.Hosting.Tests/Plugin.cs b/src/tests/Extensions.Hosting.Tests/Plugin.cs
new file mode 100644
index 0000000..f95b24a
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/Plugin.cs
@@ -0,0 +1,18 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.Extensions.DependencyInjection;
+using ReactivePlugin = ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.IPlugin;
+using StandardPlugin = ReactiveMarbles.Extensions.Hosting.Plugins.IPlugin;
+
+namespace Extensions.Hosting.Tests;
+
+/// Conventional plugin type used to cover naming-convention scanners.
+public sealed class Plugin : StandardPlugin, ReactivePlugin
+{
+ ///
+ public void ConfigureHost(object hostBuilderContext, IServiceCollection serviceCollection)
+ {
+ }
+}
diff --git a/src/tests/Extensions.Hosting.Tests/PluginScannerTests.cs b/src/tests/Extensions.Hosting.Tests/PluginScannerTests.cs
index 61a0785..9129741 100644
--- a/src/tests/Extensions.Hosting.Tests/PluginScannerTests.cs
+++ b/src/tests/Extensions.Hosting.Tests/PluginScannerTests.cs
@@ -35,12 +35,22 @@ public async Task ByNamingConvention_WithNull_ThrowsArgumentNullException()
[Test]
public async Task ByNamingConvention_FindsNoPlugin_ReturnsEmpty()
{
- var assembly = typeof(PluginScannerTests).Assembly;
+ var assembly = typeof(string).Assembly;
var plugins = PluginScanner.ByNamingConvention(assembly);
await Assert.That(plugins).IsNotNull();
await Assert.That(plugins.Any()).IsFalse();
}
+ /// Verifies that the plugin scanner discovers a plugin by naming convention.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ByNamingConvention_FindsConventionalPlugin()
+ {
+ var plugins = PluginScanner.ByNamingConvention(typeof(Plugin).Assembly).ToList();
+
+ await Assert.That(plugins.Any(plugin => plugin is Plugin)).IsTrue();
+ }
+
/// Verifies that ScanForPluginInstances returns a non-null collection for a valid assembly.
/// A task that represents the asynchronous test operation.
[Test]
diff --git a/src/tests/Extensions.Hosting.Tests/ReactiveAssemblyLoadingTests.cs b/src/tests/Extensions.Hosting.Tests/ReactiveAssemblyLoadingTests.cs
new file mode 100644
index 0000000..f49fae8
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactiveAssemblyLoadingTests.cs
@@ -0,0 +1,414 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.IO;
+using System.Reflection;
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals;
+using ReactiveAssemblyDependencyResolver = ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals.AssemblyDependencyResolver;
+using ReactiveAssemblyLoadContext = ReactiveMarbles.Extensions.Hosting.Reactive.Plugins.Internals.AssemblyLoadContext;
+
+namespace Extensions.Hosting.Tests;
+
+/// Contains tests for reactive shim plugin assembly loading helper types.
+public class ReactiveAssemblyLoadingTests
+{
+ /// Verifies that the dependency resolver resolves existing managed assembly paths.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ResolveAssemblyToPath_WithExistingAssembly_ReturnsPath()
+ {
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
+ var dependencyPath = Path.Combine(tempDirectory, "Dependency.dll");
+ await File.WriteAllTextAsync(pluginPath, string.Empty);
+ await File.WriteAllTextAsync(dependencyPath, string.Empty);
+ var resolver = new ReactiveAssemblyDependencyResolver(pluginPath);
+
+ var resolvedPath = resolver.ResolveAssemblyToPath(new AssemblyName("Dependency"));
+
+ await Assert.That(resolvedPath).IsEqualTo(dependencyPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that the dependency resolver returns null when a managed assembly does not exist.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ResolveAssemblyToPath_WithMissingAssembly_ReturnsNull()
+ {
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
+ await File.WriteAllTextAsync(pluginPath, string.Empty);
+ var resolver = new ReactiveAssemblyDependencyResolver(pluginPath);
+
+ var resolvedPath = resolver.ResolveAssemblyToPath(new AssemblyName("Missing.Dependency"));
+
+ await Assert.That(resolvedPath).IsNull();
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that the dependency resolver throws when the assembly name is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ResolveAssemblyToPath_WithNullAssemblyName_ThrowsArgumentNullException()
+ {
+ var resolver = new ReactiveAssemblyDependencyResolver($"{nameof(Plugin)}.dll");
+ AssemblyName? assemblyName = null;
+
+ var act = () => resolver.ResolveAssemblyToPath(assemblyName!);
+
+ await Assert.That(act).Throws();
+ }
+
+ /// Verifies that the dependency resolver resolves unmanaged libraries with an implicit extension.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ResolveUnmanagedDllToPath_WithImplicitExtension_ReturnsPath()
+ {
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
+ var dependencyPath = Path.Combine(tempDirectory, "native.dll");
+ await File.WriteAllTextAsync(pluginPath, string.Empty);
+ await File.WriteAllTextAsync(dependencyPath, string.Empty);
+ var resolver = new ReactiveAssemblyDependencyResolver(pluginPath);
+
+ var resolvedPath = resolver.ResolveUnmanagedDllToPath("native");
+
+ await Assert.That(resolvedPath).IsEqualTo(dependencyPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that the dependency resolver resolves unmanaged libraries with an explicit extension.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ResolveUnmanagedDllToPath_WithExplicitExtension_ReturnsPath()
+ {
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
+ var dependencyPath = Path.Combine(tempDirectory, "native.custom");
+ await File.WriteAllTextAsync(pluginPath, string.Empty);
+ await File.WriteAllTextAsync(dependencyPath, string.Empty);
+ var resolver = new ReactiveAssemblyDependencyResolver(pluginPath);
+
+ var resolvedPath = resolver.ResolveUnmanagedDllToPath("native.custom");
+
+ await Assert.That(resolvedPath).IsEqualTo(dependencyPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that the dependency resolver throws when the unmanaged library name is empty.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ResolveUnmanagedDllToPath_WithEmptyName_ThrowsArgumentException()
+ {
+ var resolver = new ReactiveAssemblyDependencyResolver($"{nameof(Plugin)}.dll");
+
+ var act = () => resolver.ResolveUnmanagedDllToPath(string.Empty);
+
+ await Assert.That(act).Throws();
+ }
+
+ /// Verifies that the load context exposes its name and loaded assemblies.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task AssemblyLoadContext_Default_ExposesNameAndAssemblies()
+ {
+ var context = ReactiveAssemblyLoadContext.Default;
+
+ await Assert.That(context.Name).IsEqualTo("default");
+ await Assert.That(ReactiveAssemblyLoadContext.Assemblies.Any()).IsTrue();
+ }
+
+ /// Verifies that loading from a null assembly name throws.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task LoadFromAssemblyName_WithNullAssemblyName_ThrowsArgumentNullException()
+ {
+ var context = new ReactiveAssemblyLoadContext("test");
+ AssemblyName? assemblyName = null;
+
+ var act = () => context.LoadFromAssemblyName(assemblyName!);
+
+ await Assert.That(act).Throws();
+ }
+
+ /// Verifies that the base load context returns null for unresolved assembly names.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task LoadFromAssemblyName_WithUnresolvedAssemblyName_ReturnsNull()
+ {
+ var context = new ReactiveAssemblyLoadContext("test");
+
+ Assembly? assembly = context.LoadFromAssemblyName(new AssemblyName("Missing.Assembly"));
+
+ await Assert.That(assembly).IsNull();
+ }
+
+ /// Verifies that a derived load context can supply an assembly from Load.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task LoadFromAssemblyName_WithDerivedLoad_ReturnsAssembly()
+ {
+ var expectedAssembly = typeof(ReactiveAssemblyLoadingTests).Assembly;
+ var context = new TestAssemblyLoadContext("test", expectedAssembly);
+
+ var assembly = context.LoadFromAssemblyName(expectedAssembly.GetName());
+
+ await Assert.That(assembly).IsEqualTo(expectedAssembly);
+ }
+
+ /// Verifies that an assembly can be loaded from an existing assembly path.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task LoadFromAssemblyPath_WithExistingAssemblyPath_ReturnsAssembly()
+ {
+ var expectedAssembly = typeof(ReactiveAssemblyLoadingTests).Assembly;
+
+ var assembly = ReactiveAssemblyLoadContext.LoadFromAssemblyPath(expectedAssembly.Location);
+
+ await Assert.That(assembly.GetName().Name).IsEqualTo(expectedAssembly.GetName().Name);
+ }
+
+ /// Verifies that native library load helpers return zero by default.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task LoadUnmanagedDllHelpers_ByDefault_ReturnZero()
+ {
+ var context = new TestAssemblyLoadContext("test", typeof(ReactiveAssemblyLoadingTests).Assembly);
+
+ await Assert.That(TestAssemblyLoadContext.LoadNativeFromPath("native.dll")).IsEqualTo(IntPtr.Zero);
+ await Assert.That(context.LoadNativeByName("native")).IsEqualTo(IntPtr.Zero);
+ }
+
+ /// Verifies that TryGetAssembly returns false when the context is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task TryGetAssembly_WithNullContext_ReturnsFalse()
+ {
+ ReactiveAssemblyLoadContext? context = null;
+
+ var result = context!.TryGetAssembly(typeof(ReactiveAssemblyLoadingTests).Assembly.GetName(), out var assembly);
+
+ await Assert.That(result).IsFalse();
+ await Assert.That(assembly).IsNull();
+ }
+
+ /// Verifies that TryGetAssembly returns true when an assembly is loaded.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task TryGetAssembly_WithLoadedAssembly_ReturnsTrue()
+ {
+ var context = ReactiveAssemblyLoadContext.Default;
+
+ var result = context.TryGetAssembly(typeof(ReactiveAssemblyLoadingTests).Assembly.GetName(), out var assembly);
+
+ await Assert.That(result).IsTrue();
+ await Assert.That(assembly).IsEqualTo(typeof(ReactiveAssemblyLoadingTests).Assembly);
+ }
+
+ /// Verifies that TryGetAssembly returns false when an assembly is not loaded.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task TryGetAssembly_WithMissingAssembly_ReturnsFalse()
+ {
+ var context = ReactiveAssemblyLoadContext.Default;
+
+ var result = context.TryGetAssembly(new AssemblyName("Missing.Plugin.Assembly"), out var assembly);
+
+ await Assert.That(result).IsFalse();
+ await Assert.That(assembly).IsNull();
+ }
+
+ /// Verifies that PluginLoadContext resolves plugin-local managed assembly paths.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginLoadContext_ResolveAssemblyPath_WithExistingDependency_ReturnsPath()
+ {
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
+ var dependencyPath = Path.Combine(tempDirectory, "Dependency.dll");
+ await File.WriteAllTextAsync(pluginPath, string.Empty);
+ await File.WriteAllTextAsync(dependencyPath, string.Empty);
+ var context = new PluginLoadContext(pluginPath, nameof(Plugin));
+
+ var resolvedPath = context.ResolveAssemblyPath(new AssemblyName("Dependency"));
+
+ await Assert.That(resolvedPath).IsEqualTo(dependencyPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that PluginLoadContext returns already-loaded assemblies from the default context.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginLoadContext_LoadFromAssemblyName_WithAlreadyLoadedAssembly_ReturnsAssembly()
+ {
+ var expectedAssembly = typeof(ReactiveAssemblyLoadingTests).Assembly;
+ var context = new PluginLoadContext(expectedAssembly.Location, nameof(Plugin));
+
+ var assembly = context.LoadFromAssemblyName(expectedAssembly.GetName());
+
+ await Assert.That(assembly).IsEqualTo(expectedAssembly);
+ }
+
+ /// Verifies that PluginLoadContext loads an assembly from the plugin directory when it is not already loaded.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginLoadContext_LoadFromAssemblyName_WithPluginLocalAssembly_ReturnsAssembly()
+ {
+ var candidatePath = GetUnloadedAssemblyPath();
+ var assemblyName = new AssemblyName(Path.GetFileNameWithoutExtension(candidatePath));
+ var context = new PluginLoadContext(candidatePath, assemblyName.Name!);
+
+ var assembly = context.LoadFromAssemblyName(assemblyName);
+
+ await Assert.That(assembly.GetName().Name).IsEqualTo(assemblyName.Name);
+ }
+
+ /// Verifies that PluginLoadContext returns null when a plugin-local assembly cannot be resolved.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginLoadContext_LoadFromAssemblyName_WithMissingAssembly_ReturnsNull()
+ {
+ var context = new PluginLoadContext(typeof(ReactiveAssemblyLoadingTests).Assembly.Location, nameof(Plugin));
+
+ Assembly? assembly = context.LoadFromAssemblyName(new AssemblyName("Missing.Plugin.Assembly"));
+
+ await Assert.That(assembly).IsNull();
+ }
+
+ /// Verifies that PluginLoadContext returns zero when an unmanaged dependency cannot be resolved.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginLoadContext_LoadUnmanagedDll_WithMissingLibrary_ReturnsZero()
+ {
+ var context = new PluginLoadContext(typeof(ReactiveAssemblyLoadingTests).Assembly.Location, nameof(Plugin));
+ var method = typeof(PluginLoadContext).GetMethod("LoadUnmanagedDll", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ var result = (IntPtr)method!.Invoke(context, ["missing"])!;
+
+ await Assert.That(result).IsEqualTo(IntPtr.Zero);
+ }
+
+ /// Verifies that PluginLoadContext resolves unmanaged dependency paths before loading.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginLoadContext_LoadUnmanagedDll_WithExistingLibrary_ReturnsZeroFromShimLoader()
+ {
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.dll");
+ var dependencyPath = Path.Combine(tempDirectory, "native.dll");
+ await File.WriteAllTextAsync(pluginPath, string.Empty);
+ await File.WriteAllTextAsync(dependencyPath, string.Empty);
+ var context = new PluginLoadContext(pluginPath, nameof(Plugin));
+ var method = typeof(PluginLoadContext).GetMethod("LoadUnmanagedDll", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ var result = (IntPtr)method!.Invoke(context, ["native"])!;
+
+ await Assert.That(result).IsEqualTo(IntPtr.Zero);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Creates a temporary directory for assembly resolver tests.
+ /// The created temporary directory.
+ private static string CreateTemporaryDirectory()
+ {
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "Extensions.Hosting.Tests", Guid.NewGuid().ToString("N"));
+ _ = Directory.CreateDirectory(tempDirectory);
+ return tempDirectory;
+ }
+
+ /// Finds a managed assembly in the test output that has not yet been loaded.
+ /// The path to an unloaded managed assembly.
+ private static string GetUnloadedAssemblyPath()
+ {
+ var assemblyDirectory = Path.GetDirectoryName(typeof(ReactiveAssemblyLoadingTests).Assembly.Location)!;
+ var loadedAssemblyNames = AppDomain.CurrentDomain.GetAssemblies()
+ .Select(assembly => assembly.GetName().Name)
+ .Where(name => name is not null)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var assemblyPath in Directory.EnumerateFiles(assemblyDirectory, "*.dll").OrderBy(Path.GetFileName))
+ {
+ var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
+ if (!loadedAssemblyNames.Contains(assemblyName) &&
+ !assemblyName.StartsWith("Extensions.Hosting", StringComparison.OrdinalIgnoreCase) &&
+ IsManagedAssembly(assemblyPath))
+ {
+ return assemblyPath;
+ }
+ }
+
+ throw new InvalidOperationException("No unloaded assembly was available in the test output directory.");
+ }
+
+ /// Returns a value indicating whether the file is a managed assembly.
+ /// The assembly path to inspect.
+ /// True when the file is a managed assembly.
+ private static bool IsManagedAssembly(string assemblyPath)
+ {
+ try
+ {
+ _ = AssemblyName.GetAssemblyName(assemblyPath);
+ return true;
+ }
+ catch (BadImageFormatException)
+ {
+ return false;
+ }
+ }
+
+ /// Test assembly load context that delegates managed loads to a supplied assembly.
+ /// The load context name.
+ /// The assembly returned from managed load requests.
+ private sealed class TestAssemblyLoadContext(string name, Assembly assembly) : ReactiveAssemblyLoadContext(name)
+ {
+ /// Calls the protected native load-from-path helper.
+ /// The native library path.
+ /// The native library handle.
+ public static IntPtr LoadNativeFromPath(string path) => LoadUnmanagedDllFromPath(path);
+
+ /// Calls the protected native load-by-name helper.
+ /// The native library name.
+ /// The native library handle.
+ public IntPtr LoadNativeByName(string name) => LoadUnmanagedDll(name);
+
+ ///
+ protected override Assembly Load(AssemblyName assemblyName) => assembly;
+ }
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ReactiveHostedServiceBaseTests.cs b/src/tests/Extensions.Hosting.Tests/ReactiveHostedServiceBaseTests.cs
new file mode 100644
index 0000000..c34c008
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactiveHostedServiceBaseTests.cs
@@ -0,0 +1,222 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins;
+
+namespace Extensions.Hosting.Tests;
+
+/// Contains unit tests for the reactive shim hosted service base lifecycle implementation.
+public class ReactiveHostedServiceBaseTests
+{
+ /// Verifies that lifetime callbacks invoke the overridable lifecycle methods.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task LifetimeCallbacks_InvokeLifecycleMethods()
+ {
+ using var lifetime = new TestHostApplicationLifetime();
+ var service = new TestHostedService(lifetime);
+
+ await service.StartAsync(CancellationToken.None).ConfigureAwait(false);
+ lifetime.TriggerStarted();
+ await service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+
+ await Assert.That(service.StartedCount).IsEqualTo(1);
+ await Assert.That(service.CleanupDisposable).IsNotNull();
+ await Assert.That(service.CleanupDisposable!.IsDisposed).IsFalse();
+
+ lifetime.TriggerStopping();
+ await Assert.That(service.StoppingCount).IsEqualTo(1);
+ await Assert.That(service.CleanupDisposable.IsDisposed).IsTrue();
+ await Assert.That(service.IsDisposed).IsTrue();
+
+ lifetime.TriggerStopped();
+ await Assert.That(service.StoppedCount).IsEqualTo(1);
+ }
+
+ /// Verifies that disposing the service is idempotent and disposes cleanup resources.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Dispose_IsIdempotentAndDisposesCleanup()
+ {
+ using var lifetime = new TestHostApplicationLifetime();
+ var service = new TestHostedService(lifetime);
+
+ await service.StartAsync(CancellationToken.None).ConfigureAwait(false);
+ lifetime.TriggerStarted();
+ await service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+
+ service.Dispose();
+ service.Dispose();
+
+ await Assert.That(service.CleanupDisposable).IsNotNull();
+ await Assert.That(service.CleanupDisposable!.IsDisposed).IsTrue();
+ await Assert.That(service.IsDisposed).IsTrue();
+ }
+
+ /// Verifies that StopAsync returns a completed task.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task StopAsync_ReturnsCompletedTask()
+ {
+ using var lifetime = new TestHostApplicationLifetime();
+ var service = new DefaultHostedService(lifetime);
+
+ var stopTask = service.StopAsync(CancellationToken.None);
+
+ await Assert.That(stopTask.IsCompletedSuccessfully).IsTrue();
+ }
+
+ /// Verifies that the default OnStarted implementation completes successfully.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task OnStarted_DefaultImplementation_CompletesSuccessfully()
+ {
+ using var lifetime = new TestHostApplicationLifetime();
+ var service = new DefaultHostedService(lifetime);
+
+ await service.OnStarted().ConfigureAwait(false);
+
+ await Assert.That(service).IsNotNull();
+ }
+
+ /// Verifies that an OnStarted exception is observed and contained by the lifetime callback.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task LifetimeStarted_WhenOnStartedThrows_DoesNotThrowFromCallback()
+ {
+ using var lifetime = new TestHostApplicationLifetime();
+ var service = new ThrowingStartedHostedService(lifetime);
+
+ await service.StartAsync(CancellationToken.None).ConfigureAwait(false);
+ lifetime.TriggerStarted();
+ await service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
+ await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false);
+
+ await Assert.That(service.StartedCount).IsEqualTo(1);
+ }
+
+ /// Test hosted service used to observe lifecycle callback execution.
+ /// The test lifetime source.
+ private sealed class TestHostedService(IHostApplicationLifetime lifetime)
+ : HostedServiceBase(NullLogger.Instance, lifetime)
+ {
+ /// Gets the started signal.
+ public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ /// Gets the cleanup disposable.
+ public TrackingDisposable? CleanupDisposable { get; private set; }
+
+ /// Gets the number of started callbacks.
+ public int StartedCount { get; private set; }
+
+ /// Gets the number of stopping callbacks.
+ public int StoppingCount { get; private set; }
+
+ /// Gets the number of stopped callbacks.
+ public int StoppedCount { get; private set; }
+
+ ///
+ public override Task OnStarted()
+ {
+ StartedCount++;
+ CleanupDisposable = new();
+ CleanUp.Add(CleanupDisposable);
+ _ = Started.TrySetResult(true);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public override void OnStopping()
+ {
+ StoppingCount++;
+ base.OnStopping();
+ }
+
+ ///
+ public override void OnStopped()
+ {
+ StoppedCount++;
+ base.OnStopped();
+ }
+ }
+
+ /// Hosted service that uses the base lifecycle implementations.
+ /// The test lifetime source.
+ private sealed class DefaultHostedService(IHostApplicationLifetime lifetime)
+ : HostedServiceBase(NullLogger.Instance, lifetime);
+
+ /// Hosted service that throws from OnStarted.
+ /// The test lifetime source.
+ private sealed class ThrowingStartedHostedService(IHostApplicationLifetime lifetime)
+ : HostedServiceBase(NullLogger.Instance, lifetime)
+ {
+ /// Gets the started signal.
+ public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ /// Gets the number of started callbacks.
+ public int StartedCount { get; private set; }
+
+ ///
+ public override Task OnStarted()
+ {
+ StartedCount++;
+ _ = Started.TrySetResult(true);
+ throw new InvalidOperationException("Start failure for test coverage.");
+ }
+ }
+
+ /// Disposable used to verify cleanup disposal.
+ private sealed class TrackingDisposable : IDisposable
+ {
+ /// Gets a value indicating whether this instance was disposed.
+ public bool IsDisposed { get; private set; }
+
+ ///
+ public void Dispose() => IsDisposed = true;
+ }
+
+ /// Test application lifetime that exposes manual trigger methods.
+ private sealed class TestHostApplicationLifetime : IHostApplicationLifetime, IDisposable
+ {
+ /// Stores the started token source.
+ private readonly CancellationTokenSource _started = new();
+
+ /// Stores the stopping token source.
+ private readonly CancellationTokenSource _stopping = new();
+
+ /// Stores the stopped token source.
+ private readonly CancellationTokenSource _stopped = new();
+
+ ///
+ public CancellationToken ApplicationStarted => _started.Token;
+
+ ///
+ public CancellationToken ApplicationStopping => _stopping.Token;
+
+ ///
+ public CancellationToken ApplicationStopped => _stopped.Token;
+
+ /// Triggers the started token.
+ public void TriggerStarted() => _started.Cancel();
+
+ /// Triggers the stopping token.
+ public void TriggerStopping() => _stopping.Cancel();
+
+ /// Triggers the stopped token.
+ public void TriggerStopped() => _stopped.Cancel();
+
+ ///
+ public void StopApplication() => TriggerStopping();
+
+ ///
+ public void Dispose()
+ {
+ _started.Dispose();
+ _stopping.Dispose();
+ _stopped.Dispose();
+ }
+ }
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ReactivePluginBaseTests.cs b/src/tests/Extensions.Hosting.Tests/ReactivePluginBaseTests.cs
new file mode 100644
index 0000000..215675d
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactivePluginBaseTests.cs
@@ -0,0 +1,101 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins;
+
+namespace Extensions.Hosting.Tests;
+
+/// Contains tests for reactive shim plugin base hosted-service registration helpers.
+public class ReactivePluginBaseTests
+{
+ /// Verifies that PluginBase with one hosted service registers that service.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigureHost_WithOneHostedService_RegistersService()
+ {
+ var services = new ServiceCollection();
+ var plugin = new PluginBase();
+
+ plugin.ConfigureHost(new object(), services);
+
+ await Assert.That(services.Any(IsFirstHostedServiceRegistration)).IsTrue();
+ }
+
+ /// Verifies that PluginBase with two hosted services registers both services.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigureHost_WithTwoHostedServices_RegistersServices()
+ {
+ var services = new ServiceCollection();
+ var plugin = new PluginBase();
+
+ plugin.ConfigureHost(new object(), services);
+
+ await Assert.That(services.Any(IsFirstHostedServiceRegistration)).IsTrue();
+ await Assert.That(services.Any(IsSecondHostedServiceRegistration)).IsTrue();
+ }
+
+ /// Verifies that PluginBase with three hosted services registers all services.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigureHost_WithThreeHostedServices_RegistersServices()
+ {
+ var services = new ServiceCollection();
+ var plugin = new PluginBase();
+
+ plugin.ConfigureHost(new object(), services);
+
+ await Assert.That(services.Any(IsFirstHostedServiceRegistration)).IsTrue();
+ await Assert.That(services.Any(IsSecondHostedServiceRegistration)).IsTrue();
+ await Assert.That(services.Any(IsThirdHostedServiceRegistration)).IsTrue();
+ }
+
+ /// Returns a value indicating whether the descriptor registers the first hosted service.
+ /// The service descriptor to inspect.
+ /// True when the descriptor registers the first hosted service.
+ private static bool IsFirstHostedServiceRegistration(ServiceDescriptor serviceDescriptor) =>
+ IsHostedServiceRegistration(serviceDescriptor);
+
+ /// Returns a value indicating whether the descriptor registers the second hosted service.
+ /// The service descriptor to inspect.
+ /// True when the descriptor registers the second hosted service.
+ private static bool IsSecondHostedServiceRegistration(ServiceDescriptor serviceDescriptor) =>
+ IsHostedServiceRegistration(serviceDescriptor);
+
+ /// Returns a value indicating whether the descriptor registers the third hosted service.
+ /// The service descriptor to inspect.
+ /// True when the descriptor registers the third hosted service.
+ private static bool IsThirdHostedServiceRegistration(ServiceDescriptor serviceDescriptor) =>
+ IsHostedServiceRegistration(serviceDescriptor);
+
+ /// Returns a value indicating whether the descriptor registers the requested hosted service type.
+ /// The hosted service implementation type.
+ /// The service descriptor to inspect.
+ /// True when the descriptor registers the requested hosted service type.
+ private static bool IsHostedServiceRegistration(ServiceDescriptor serviceDescriptor)
+ where T : class, IHostedService =>
+ serviceDescriptor.ServiceType == typeof(IHostedService) &&
+ serviceDescriptor.ImplementationType == typeof(T);
+
+ /// First hosted service used for reactive registration tests.
+ public sealed class FirstReactiveHostedService : ReactiveHostedService;
+
+ /// Second hosted service used for reactive registration tests.
+ public sealed class SecondReactiveHostedService : ReactiveHostedService;
+
+ /// Third hosted service used for reactive registration tests.
+ public sealed class ThirdReactiveHostedService : ReactiveHostedService;
+
+ /// Base hosted service used for reactive registration tests.
+ public abstract class ReactiveHostedService : IHostedService
+ {
+ ///
+ public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ReactivePluginBuilderExtensionsTests.cs b/src/tests/Extensions.Hosting.Tests/ReactivePluginBuilderExtensionsTests.cs
new file mode 100644
index 0000000..486ee26
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactivePluginBuilderExtensionsTests.cs
@@ -0,0 +1,204 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.IO;
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins;
+
+namespace Extensions.Hosting.Tests;
+
+/// Contains unit tests for the reactive shim PluginBuilderExtensions class.
+public class ReactivePluginBuilderExtensionsTests
+{
+ /// Verifies that AddScanDirectories throws when directories is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task AddScanDirectories_WithNullDirectories_ThrowsArgumentNullException()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ void Act() => PluginBuilderExtensions.AddScanDirectories(builder, null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that AddScanDirectories adds directories to both collections.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task AddScanDirectories_WithValidDirectories_AddsToBothCollections()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ var testDir = Path.GetTempPath();
+
+ builder.AddScanDirectories(testDir);
+
+ await Assert.That(builder.PluginDirectories.Count).IsEqualTo(1);
+ await Assert.That(builder.FrameworkDirectories.Count).IsEqualTo(1);
+ }
+
+ /// Verifies that multiple directories can be added.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task AddScanDirectories_MultipleDirectories_AddsAll()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ var dir1 = Path.GetTempPath();
+ var dir2 = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+ builder.AddScanDirectories(dir1, dir2);
+
+ await Assert.That(builder.PluginDirectories.Count).IsEqualTo(2);
+ await Assert.That(builder.FrameworkDirectories.Count).IsEqualTo(2);
+ }
+
+ /// Verifies that ExcludeFrameworks throws when frameworkGlobs is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ExcludeFrameworks_WithNullGlobs_ThrowsArgumentNullException()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ void Act() => PluginBuilderExtensions.ExcludeFrameworks(builder, null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that ExcludeFrameworks does not throw with valid input.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ExcludeFrameworks_WithValidGlobs_DoesNotThrow()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ Exception? exception = null;
+
+ try
+ {
+ builder.ExcludeFrameworks("**/bin/**");
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+
+ await Assert.That(exception).IsNull();
+ }
+
+ /// Verifies that ExcludePlugins throws when pluginGlobs is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ExcludePlugins_WithNullGlobs_ThrowsArgumentNullException()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ void Act() => PluginBuilderExtensions.ExcludePlugins(builder, null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that ExcludePlugins does not throw with valid input.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ExcludePlugins_WithValidGlobs_DoesNotThrow()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ Exception? exception = null;
+
+ try
+ {
+ builder.ExcludePlugins("**/test/**");
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+
+ await Assert.That(exception).IsNull();
+ }
+
+ /// Verifies that IncludeFrameworks throws when frameworkGlobs is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task IncludeFrameworks_WithNullGlobs_ThrowsArgumentNullException()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ void Act() => PluginBuilderExtensions.IncludeFrameworks(builder, null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that IncludeFrameworks does not throw with valid input.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task IncludeFrameworks_WithValidGlobs_DoesNotThrow()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ Exception? exception = null;
+
+ try
+ {
+ builder.IncludeFrameworks("**/*.dll");
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+
+ await Assert.That(exception).IsNull();
+ }
+
+ /// Verifies that IncludePlugins throws when pluginGlobs is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task IncludePlugins_WithNullGlobs_ThrowsArgumentNullException()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ void Act() => PluginBuilderExtensions.IncludePlugins(builder, null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that IncludePlugins does not throw with valid input.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task IncludePlugins_WithValidGlobs_DoesNotThrow()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ Exception? exception = null;
+
+ try
+ {
+ builder.IncludePlugins("**/*Plugin.dll");
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+
+ await Assert.That(exception).IsNull();
+ }
+
+ /// Verifies that RequirePlugins throws when pluginBuilder is null.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task RequirePlugins_WithNullBuilder_ThrowsArgumentNullException()
+ {
+ static void Act() => PluginBuilderExtensions.RequirePlugins(null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that RequirePlugins sets FailIfNoPlugins to true by default.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task RequirePlugins_Default_SetsFailIfNoPluginsTrue()
+ {
+ var builder = new ReactiveTestPluginBuilder();
+ builder.RequirePlugins();
+ await Assert.That(builder.FailIfNoPlugins).IsTrue();
+ }
+
+ /// Verifies that RequirePlugins sets FailIfNoPlugins to false when specified.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task RequirePlugins_WithFalse_SetsFailIfNoPluginsFalse()
+ {
+ var builder = new ReactiveTestPluginBuilder
+ {
+ FailIfNoPlugins = true
+ };
+
+ builder.RequirePlugins(false);
+ await Assert.That(builder.FailIfNoPlugins).IsFalse();
+ }
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ReactivePluginOrderAttributeTests.cs b/src/tests/Extensions.Hosting.Tests/ReactivePluginOrderAttributeTests.cs
new file mode 100644
index 0000000..2d84690
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactivePluginOrderAttributeTests.cs
@@ -0,0 +1,63 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins;
+
+namespace Extensions.Hosting.Tests;
+
+/// Contains unit tests for the reactive shim PluginOrderAttribute class.
+public class ReactivePluginOrderAttributeTests
+{
+ /// Verifies that the default constructor sets Order to 0.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task DefaultConstructor_SetsOrderToZero()
+ {
+ var attribute = new PluginOrderAttribute();
+ await Assert.That(attribute.Order).IsEqualTo(0);
+ }
+
+ /// Verifies that the int constructor sets the Order property correctly.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task IntConstructor_SetsOrderCorrectly()
+ {
+ var attribute = new PluginOrderAttribute(42);
+ await Assert.That(attribute.Order).IsEqualTo(42);
+ }
+
+ /// Verifies that the int constructor handles negative values.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task IntConstructor_WithNegativeValue_SetsOrderCorrectly()
+ {
+ var attribute = new PluginOrderAttribute(-10);
+ await Assert.That(attribute.Order).IsEqualTo(-10);
+ }
+
+ /// Verifies that the enum constructor converts the enum to its int value.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task EnumConstructor_ConvertsToIntValue()
+ {
+ var attribute = new PluginOrderAttribute(TestOrder.High);
+ await Assert.That(attribute.Order).IsEqualTo((int)TestOrder.High);
+ }
+
+ /// Verifies that the attribute can be retrieved from a decorated class.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Attribute_CanBeRetrievedFromClass()
+ {
+ var type = typeof(ReactiveOrderedTestPlugin);
+ var attribute = (PluginOrderAttribute?)Attribute.GetCustomAttribute(type, typeof(PluginOrderAttribute));
+
+ await Assert.That(attribute).IsNotNull();
+ await Assert.That(attribute!.Order).IsEqualTo(100);
+ }
+
+ /// A reactive shim plugin with a custom order attribute for testing plugin ordering.
+ [PluginOrder(100)]
+ public class ReactiveOrderedTestPlugin : ReactivePluginScannerTests.ReactiveScannerTestPlugin;
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ReactivePluginScannerTests.cs b/src/tests/Extensions.Hosting.Tests/ReactivePluginScannerTests.cs
new file mode 100644
index 0000000..c3c8a60
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactivePluginScannerTests.cs
@@ -0,0 +1,112 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.Extensions.DependencyInjection;
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins;
+
+namespace Extensions.Hosting.Tests;
+
+/// Contains unit tests for the reactive shim PluginScanner class.
+public class ReactivePluginScannerTests
+{
+ /// Verifies that ScanForPluginInstances throws an ArgumentNullException when passed a null argument.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ScanForPluginInstances_WithNull_ThrowsArgumentNullException()
+ {
+ static IEnumerable Act() => PluginScanner.ScanForPluginInstances(null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that ByNamingConvention throws an ArgumentNullException when passed a null argument.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ByNamingConvention_WithNull_ThrowsArgumentNullException()
+ {
+ static IEnumerable Act() => PluginScanner.ByNamingConvention(null!);
+ await Assert.That(Act).Throws();
+ }
+
+ /// Verifies that naming convention scanning returns empty when no conventional plugin exists.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ByNamingConvention_FindsNoPlugin_ReturnsEmpty()
+ {
+ var plugins = PluginScanner.ByNamingConvention(typeof(string).Assembly);
+
+ await Assert.That(plugins).IsNotNull();
+ await Assert.That(plugins.Any()).IsFalse();
+ }
+
+ /// Verifies that the reactive shim plugin scanner discovers a plugin by naming convention.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ByNamingConvention_FindsConventionalPlugin()
+ {
+ var plugins = PluginScanner.ByNamingConvention(typeof(Plugin).Assembly).ToList();
+
+ await Assert.That(plugins.Any(plugin => plugin is Plugin)).IsTrue();
+ }
+
+ /// Verifies that ScanForPluginInstances returns a non-null collection for a valid assembly.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ScanForPluginInstances_WithValidAssembly_ReturnsNonNullCollection()
+ {
+ var plugins = PluginScanner.ScanForPluginInstances(typeof(ReactivePluginScannerTests).Assembly);
+
+ await Assert.That(plugins).IsNotNull();
+ }
+
+ /// Verifies that ScanForPluginInstances discovers the reactive test plugin.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ScanForPluginInstances_FindsReactiveTestPlugin()
+ {
+ var plugins = PluginScanner.ScanForPluginInstances(typeof(ReactiveScannerTestPlugin).Assembly).ToList();
+
+ await Assert.That(plugins.Count).IsGreaterThanOrEqualTo(1);
+ await Assert.That(plugins.Any(plugin => plugin is ReactiveScannerTestPlugin)).IsTrue();
+ }
+
+ /// Verifies that ScanForPluginInstances does not include abstract reactive plugin classes.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ScanForPluginInstances_DoesNotIncludeAbstractPlugins()
+ {
+ var plugins = PluginScanner.ScanForPluginInstances(typeof(ReactiveAbstractTestPlugin).Assembly).ToList();
+
+ await Assert.That(plugins.Any(plugin => plugin.GetType() == typeof(ReactiveAbstractTestPlugin))).IsFalse();
+ }
+
+ /// Verifies that the reactive test plugin can be configured via ConfigureHost.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ReactiveTestPlugin_ConfigureHost_SetsConfiguredFlag()
+ {
+ var plugin = new ReactiveScannerTestPlugin();
+ var services = new ServiceCollection();
+
+ plugin.ConfigureHost(new object(), services);
+
+ await Assert.That(plugin.WasConfigured).IsTrue();
+ }
+
+ /// A reactive shim test plugin implementation for unit testing purposes.
+ public class ReactiveScannerTestPlugin : IPlugin
+ {
+ /// Gets a value indicating whether ConfigureHost was called.
+ public bool WasConfigured { get; private set; }
+
+ ///
+ public void ConfigureHost(object hostBuilderContext, IServiceCollection serviceCollection) => WasConfigured = true;
+ }
+
+ /// An abstract reactive shim plugin used to test that abstract classes are not discovered.
+ public abstract class ReactiveAbstractTestPlugin : IPlugin
+ {
+ ///
+ public abstract void ConfigureHost(object hostBuilderContext, IServiceCollection serviceCollection);
+ }
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ReactivePluginShimTests.cs b/src/tests/Extensions.Hosting.Tests/ReactivePluginShimTests.cs
new file mode 100644
index 0000000..ac842df
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactivePluginShimTests.cs
@@ -0,0 +1,364 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.IO;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins;
+
+namespace Extensions.Hosting.Tests;
+
+/// Contains tests for the reactive plugin shim surface.
+public class ReactivePluginShimTests
+{
+ /// Verifies that the reactive shim PluginBase registers hosted services.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginBase_WithReactiveNamespace_RegistersHostedService()
+ {
+ var services = new ServiceCollection();
+ var plugin = new PluginBase();
+
+ plugin.ConfigureHost(new object(), services);
+
+ await Assert.That(services.Any(IsReactiveHostedServiceRegistration)).IsTrue();
+ }
+
+ /// Verifies that the reactive shim plugin scanner discovers reactive plugin implementations.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task PluginScanner_WithReactiveNamespace_FindsReactivePlugin()
+ {
+ var plugins = PluginScanner.ScanForPluginInstances(typeof(ReactiveDiscoveredPlugin).Assembly).ToList();
+
+ await Assert.That(plugins.Any(plugin => plugin is ReactiveDiscoveredPlugin)).IsTrue();
+ }
+
+ /// Verifies that the reactive shim host builder extension configures discovered plugins in order.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_WithReactiveNamespace_LoadsConfiguredPluginsInOrder()
+ {
+ var configuredPlugins = new List();
+ var hostBuilder = Host.CreateDefaultBuilder();
+
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ AddCurrentAssemblyAsFramework(builder);
+ builder.AssemblyScanFunc = _ =>
+ [
+ new LaterReactiveRecordingPlugin(configuredPlugins),
+ null,
+ new EarlierReactiveRecordingPlugin(configuredPlugins),
+ ];
+ });
+
+ using var host = hostBuilder.Build();
+
+ await Assert.That(configuredPlugins.Count).IsEqualTo(2);
+ await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierReactiveRecordingPlugin.Name);
+ await Assert.That(configuredPlugins[1]).IsEqualTo(LaterReactiveRecordingPlugin.Name);
+ }
+
+ /// Verifies that ConfigurePlugins with a null IHostBuilder throws.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_WithNullHostBuilder_ThrowsArgumentNullException()
+ {
+ IHostBuilder? hostBuilder = null;
+ var act = () => hostBuilder!.ConfigurePlugins(_ => { });
+ await Assert.That(act).Throws();
+ }
+
+ /// Verifies that ConfigurePlugins with a null IHostApplicationBuilder throws.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostApplicationBuilder_WithNullHostBuilder_ThrowsArgumentNullException()
+ {
+ IHostApplicationBuilder? hostBuilder = null;
+ var act = () => hostBuilder!.ConfigurePlugins(_ => { });
+ await Assert.That(act).Throws();
+ }
+
+ /// Verifies that IHostApplicationBuilder applies caller configuration before scanning for plugins.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostApplicationBuilder_AppliesConfigurationBeforeScanning()
+ {
+ var configuredPlugins = new List();
+ var hostBuilder = Host.CreateApplicationBuilder();
+
+ var result = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ AddCurrentAssemblyAsFramework(builder);
+ builder.AssemblyScanFunc = _ => [new EarlierReactiveRecordingPlugin(configuredPlugins)];
+ });
+
+ await Assert.That(result).IsEqualTo(hostBuilder);
+ await Assert.That(configuredPlugins.Count).IsEqualTo(1);
+ await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierReactiveRecordingPlugin.Name);
+ }
+
+ /// Verifies that repeated IHostBuilder plugin configuration calls reuse the same builder instance.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_MultipleCalls_ReusePluginBuilder()
+ {
+ IPluginBuilder? firstBuilder = null;
+ IPluginBuilder? secondBuilder = null;
+ var hostBuilder = Host.CreateDefaultBuilder();
+
+ _ = hostBuilder.ConfigurePlugins(builder => firstBuilder = builder);
+ _ = hostBuilder.ConfigurePlugins(builder => secondBuilder = builder);
+
+ await Assert.That(firstBuilder).IsNotNull();
+ await Assert.That(secondBuilder).IsNotNull();
+ await Assert.That(firstBuilder).IsEqualTo(secondBuilder);
+ }
+
+ /// Verifies that repeated IHostApplicationBuilder plugin configuration calls reuse the same builder instance.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostApplicationBuilder_MultipleCalls_ReusePluginBuilder()
+ {
+ IPluginBuilder? firstBuilder = null;
+ IPluginBuilder? secondBuilder = null;
+ var hostBuilder = Host.CreateApplicationBuilder();
+
+ _ = hostBuilder.ConfigurePlugins(builder => firstBuilder = builder);
+ _ = hostBuilder.ConfigurePlugins(builder => secondBuilder = builder);
+
+ await Assert.That(firstBuilder).IsNotNull();
+ await Assert.That(secondBuilder).IsNotNull();
+ await Assert.That(firstBuilder).IsEqualTo(secondBuilder);
+ }
+
+ /// Verifies that content-root scanning can provide the assembly used for plugin discovery.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_WithContentRootScan_LoadsConfiguredPlugin()
+ {
+ var configuredPlugins = new List();
+ var assemblyPath = typeof(ReactivePluginShimTests).Assembly.Location;
+ var hostBuilder = Host.CreateDefaultBuilder()
+ .UseContentRoot(Path.GetDirectoryName(assemblyPath)!);
+
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.UseContentRoot = true;
+ _ = builder.FrameworkMatcher.AddInclude(Path.GetFileName(assemblyPath));
+ builder.AssemblyScanFunc = _ => [new EarlierReactiveRecordingPlugin(configuredPlugins)];
+ });
+
+ using var host = hostBuilder.Build();
+
+ await Assert.That(configuredPlugins.Count).IsEqualTo(1);
+ await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierReactiveRecordingPlugin.Name);
+ }
+
+ /// Verifies that invalid plugin files discovered by the plugin matcher are ignored.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_WithInvalidPluginFile_IgnoresPlugin()
+ {
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var pluginPath = Path.Combine(tempDirectory, "InvalidPlugin.dll");
+ await File.WriteAllTextAsync(pluginPath, string.Empty);
+ var hostBuilder = Host.CreateDefaultBuilder();
+
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.PluginDirectories.Add(tempDirectory);
+ _ = builder.PluginMatcher.AddInclude(Path.GetFileName(pluginPath));
+ builder.ValidatePlugin = _ => false;
+ });
+
+ using var host = hostBuilder.Build();
+ await Assert.That(host).IsNotNull();
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that plugin files already loaded in the default context are ignored.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_WithAlreadyLoadedPluginFile_IgnoresPlugin()
+ {
+ var assemblyPath = typeof(ReactivePluginShimTests).Assembly.Location;
+ var hostBuilder = Host.CreateDefaultBuilder();
+
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.PluginDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
+ _ = builder.PluginMatcher.AddInclude(Path.GetFileName(assemblyPath));
+ });
+
+ using var host = hostBuilder.Build();
+ await Assert.That(host).IsNotNull();
+ }
+
+ /// Verifies that framework assemblies not yet loaded in the default context are loaded and scanned.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_WithUnloadedFrameworkAssembly_LoadsAndScansAssembly()
+ {
+ var configuredPlugins = new List();
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var assemblyPath = CopyCurrentAssemblyToTemporaryPlugin(tempDirectory);
+ var hostBuilder = Host.CreateDefaultBuilder();
+
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.FrameworkDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
+ _ = builder.FrameworkMatcher.AddInclude(Path.GetFileName(assemblyPath));
+ builder.AssemblyScanFunc = _ => [new EarlierReactiveRecordingPlugin(configuredPlugins)];
+ });
+
+ using var host = hostBuilder.Build();
+
+ await Assert.That(configuredPlugins.Count).IsEqualTo(1);
+ await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierReactiveRecordingPlugin.Name);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that plugin assemblies not yet loaded in the default context are loaded and scanned.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_WithUnloadedPluginAssembly_LoadsAndScansAssembly()
+ {
+ var configuredPlugins = new List();
+ var tempDirectory = CreateTemporaryDirectory();
+ try
+ {
+ var assemblyPath = CopyCurrentAssemblyToTemporaryPlugin(tempDirectory);
+ var hostBuilder = Host.CreateDefaultBuilder();
+
+ _ = hostBuilder.ConfigurePlugins(builder =>
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.PluginDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
+ _ = builder.PluginMatcher.AddInclude(Path.GetFileName(assemblyPath));
+ builder.AssemblyScanFunc = _ => [new EarlierReactiveRecordingPlugin(configuredPlugins)];
+ });
+
+ using var host = hostBuilder.Build();
+
+ await Assert.That(configuredPlugins.Count).IsEqualTo(1);
+ await Assert.That(configuredPlugins[0]).IsEqualTo(EarlierReactiveRecordingPlugin.Name);
+ }
+ finally
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ /// Verifies that required plugin configuration fails when no plugins are discovered.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task ConfigurePlugins_IHostBuilder_WithRequiredPluginsAndNoPlugins_ThrowsInvalidOperationException()
+ {
+ var hostBuilder = Host.CreateDefaultBuilder();
+ _ = hostBuilder.ConfigurePlugins(builder => builder.RequirePlugins());
+
+ var act = () => hostBuilder.Build();
+ await Assert.That(act).Throws();
+ }
+
+ /// Adds the current test assembly to the framework scan set.
+ /// The plugin builder to configure.
+ private static void AddCurrentAssemblyAsFramework(IPluginBuilder pluginBuilder)
+ {
+ var assemblyPath = typeof(ReactivePluginShimTests).Assembly.Location;
+ pluginBuilder.FrameworkDirectories.Add(Path.GetDirectoryName(assemblyPath)!);
+ _ = pluginBuilder.FrameworkMatcher.AddInclude(Path.GetFileName(assemblyPath));
+ }
+
+ /// Creates a temporary directory for plugin scanning tests.
+ /// The created temporary directory.
+ private static string CreateTemporaryDirectory()
+ {
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "Extensions.Hosting.Tests", Guid.NewGuid().ToString("N"));
+ _ = Directory.CreateDirectory(tempDirectory);
+ return tempDirectory;
+ }
+
+ /// Copies the current test assembly to a unique plugin assembly path.
+ /// The temporary directory to copy into.
+ /// The copied assembly path.
+ private static string CopyCurrentAssemblyToTemporaryPlugin(string tempDirectory)
+ {
+ var pluginPath = Path.Combine(tempDirectory, $"{nameof(Plugin)}.{Guid.NewGuid():N}.dll");
+ File.Copy(typeof(ReactivePluginShimTests).Assembly.Location, pluginPath);
+ return pluginPath;
+ }
+
+ /// Returns a value indicating whether the descriptor registers the reactive hosted service.
+ /// The service descriptor to inspect.
+ /// True when the descriptor registers the reactive hosted service.
+ private static bool IsReactiveHostedServiceRegistration(ServiceDescriptor serviceDescriptor) =>
+ serviceDescriptor.ServiceType == typeof(IHostedService) &&
+ serviceDescriptor.ImplementationType == typeof(ReactiveHostedService);
+
+ /// Hosted service used to verify reactive shim service registration.
+ public sealed class ReactiveHostedService : IHostedService
+ {
+ ///
+ public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ /// Reactive plugin implementation discovered by the reactive shim scanner.
+ public sealed class ReactiveDiscoveredPlugin : IPlugin
+ {
+ ///
+ public void ConfigureHost(object hostBuilderContext, IServiceCollection serviceCollection)
+ {
+ }
+ }
+
+ /// Records configuration with an earlier reactive plugin order.
+ /// The configured plugin log.
+ [PluginOrder(-1)]
+ private sealed class EarlierReactiveRecordingPlugin(List configuredPlugins) : IPlugin
+ {
+ /// Stores the plugin name.
+ public const string Name = "EarlierReactive";
+
+ ///
+ public void ConfigureHost(object hostBuilderContext, IServiceCollection serviceCollection) =>
+ configuredPlugins.Add(Name);
+ }
+
+ /// Records configuration with a later reactive plugin order.
+ /// The configured plugin log.
+ [PluginOrder(10)]
+ private sealed class LaterReactiveRecordingPlugin(List configuredPlugins) : IPlugin
+ {
+ /// Stores the plugin name.
+ public const string Name = "LaterReactive";
+
+ ///
+ public void ConfigureHost(object hostBuilderContext, IServiceCollection serviceCollection) =>
+ configuredPlugins.Add(Name);
+ }
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ReactiveTestPluginBuilder.cs b/src/tests/Extensions.Hosting.Tests/ReactiveTestPluginBuilder.cs
new file mode 100644
index 0000000..92950ff
--- /dev/null
+++ b/src/tests/Extensions.Hosting.Tests/ReactiveTestPluginBuilder.cs
@@ -0,0 +1,37 @@
+// Copyright (c) 2016-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Reflection;
+using Microsoft.Extensions.FileSystemGlobbing;
+using ReactiveMarbles.Extensions.Hosting.Reactive.Plugins;
+
+namespace Extensions.Hosting.Tests;
+
+/// Test implementation of the reactive shim IPluginBuilder for unit testing.
+public class ReactiveTestPluginBuilder : IPluginBuilder
+{
+ ///
+ public IList PluginDirectories { get; } = new List();
+
+ ///
+ public IList FrameworkDirectories { get; } = new List();
+
+ ///
+ public bool UseContentRoot { get; set; }
+
+ ///
+ public bool FailIfNoPlugins { get; set; }
+
+ ///
+ public Matcher FrameworkMatcher { get; } = new Matcher();
+
+ ///
+ public Matcher PluginMatcher { get; } = new Matcher();
+
+ ///
+ public Func ValidatePlugin { get; set; } = _ => true;
+
+ ///
+ public Func?> AssemblyScanFunc { get; set; } = PluginScanner.ScanForPluginInstances;
+}
diff --git a/src/tests/Extensions.Hosting.Tests/ResourceMutexTests.cs b/src/tests/Extensions.Hosting.Tests/ResourceMutexTests.cs
index e35868a..cbad204 100644
--- a/src/tests/Extensions.Hosting.Tests/ResourceMutexTests.cs
+++ b/src/tests/Extensions.Hosting.Tests/ResourceMutexTests.cs
@@ -104,6 +104,81 @@ public async Task Create_SecondLocalMutexWithSameId_IsNotLocked()
await Assert.That(second.IsLocked).IsFalse();
}
+ /// Verifies that ResourceMutex can claim an existing named mutex that is not owned.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Create_WhenNamedMutexExistsButIsUnowned_ClaimsMutex()
+ {
+ var logger = NullLogger.Instance;
+ var id = "test-mutex-existing-" + Guid.NewGuid().ToString("N");
+ using var existing = new Mutex(initiallyOwned: false, @"Local\" + id);
+
+ using var mutex = ResourceMutex.Create(logger, id);
+
+ await Assert.That(mutex.IsLocked).IsTrue();
+ }
+
+ /// Verifies that ResourceMutex reports an unlocked state when mutex creation fails.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Create_WithInvalidMutexName_ReturnsUnlockedMutex()
+ {
+ var logger = NullLogger.Instance;
+ var id = "test-mutex-invalid-" + Guid.NewGuid().ToString("N") + @"\child";
+
+ using var mutex = ResourceMutex.Create(logger, id);
+
+ await Assert.That(mutex.IsLocked).IsFalse();
+ }
+
+ /// Verifies that ResourceMutex recovers ownership from an abandoned named mutex.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Create_WhenNamedMutexIsAbandoned_RecoversMutex()
+ {
+ var logger = NullLogger.Instance;
+ var id = "test-mutex-abandoned-" + Guid.NewGuid().ToString("N");
+ var ready = new ManualResetEventSlim(initialState: false);
+ var abandonThread = new Thread(() =>
+ {
+ _ = new Mutex(initiallyOwned: true, @"Local\" + id);
+ ready.Set();
+ });
+
+ abandonThread.Start();
+ ready.Wait();
+ abandonThread.Join();
+
+ using var mutex = ResourceMutex.Create(logger, id);
+
+ await Assert.That(mutex.IsLocked).IsTrue();
+ }
+
+ /// Verifies that ResourceMutex reports an unlocked state when mutex creation is unauthorized.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Create_WhenMutexFactoryThrowsUnauthorized_ReturnsUnlockedMutex()
+ {
+ static (Mutex Mutex, bool CreatedNew) ThrowUnauthorized(string mutexId)
+ {
+ _ = mutexId;
+ throw new UnauthorizedAccessException();
+ }
+
+ using var mutex = new ResourceMutex(
+ NullLogger.Instance,
+ "test-mutex-unauthorized-" + Guid.NewGuid().ToString("N"),
+ resourceName: null,
+ ThrowUnauthorized,
+ static mutex => mutex.ReleaseMutex(),
+ TimeSpan.FromSeconds(5));
+
+ var locked = mutex.Lock();
+
+ await Assert.That(locked).IsFalse();
+ await Assert.That(mutex.IsLocked).IsFalse();
+ }
+
/// Verifies that ResourceMutex can be created as a global mutex.
/// A task that represents the asynchronous test operation.
[Test]
@@ -195,4 +270,90 @@ public async Task Dispose_FromDifferentThread_DoesNotThrow()
await Assert.That(exception).IsNull();
await Assert.That(mutex.IsLocked).IsFalse();
}
+
+ /// Verifies that ResourceMutex does not throw when the owner thread cannot exit before the dispose timeout.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Dispose_WhenOwnerThreadDoesNotExitWithinTimeout_DoesNotThrow()
+ {
+ var id = "test-mutex-timeout-" + Guid.NewGuid().ToString("N");
+ using var releaseStarted = new ManualResetEventSlim(initialState: false);
+ using var releaseCanContinue = new ManualResetEventSlim(initialState: false);
+ var mutex = new ResourceMutex(
+ NullLogger.Instance,
+ @"Local\" + id,
+ resourceName: null,
+ CreateNamedMutex,
+ static mutex => mutex.ReleaseMutex(),
+ TimeSpan.FromSeconds(1),
+ beforeReleaseMutex: () =>
+ {
+ releaseStarted.Set();
+ releaseCanContinue.Wait();
+ });
+
+ Exception? exception = null;
+ try
+ {
+ var locked = mutex.Lock();
+ await Assert.That(locked).IsTrue();
+
+ mutex.Dispose();
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+ finally
+ {
+ releaseCanContinue.Set();
+ }
+
+ await Assert.That(exception).IsNull();
+ await Assert.That(releaseStarted.IsSet).IsTrue();
+ }
+
+ /// Verifies that ResourceMutex does not throw when releasing the underlying mutex fails.
+ /// A task that represents the asynchronous test operation.
+ [Test]
+ public async Task Dispose_WhenReleaseOperationThrows_DoesNotThrow()
+ {
+ static void ReleaseThenThrow(Mutex mutex)
+ {
+ mutex.ReleaseMutex();
+ throw new InvalidOperationException("Release failure.");
+ }
+
+ var id = "test-mutex-release-failure-" + Guid.NewGuid().ToString("N");
+ var mutex = new ResourceMutex(
+ NullLogger.Instance,
+ @"Local\" + id,
+ resourceName: null,
+ CreateNamedMutex,
+ ReleaseThenThrow,
+ TimeSpan.FromSeconds(5));
+
+ Exception? exception = null;
+ try
+ {
+ var locked = mutex.Lock();
+ await Assert.That(locked).IsTrue();
+
+ mutex.Dispose();
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+
+ await Assert.That(exception).IsNull();
+ }
+
+ /// Creates a named mutex for ResourceMutex test seams.
+ /// The fully qualified mutex identifier.
+ /// The created mutex and a value indicating whether it was newly created.
+ private static (Mutex Mutex, bool CreatedNew) CreateNamedMutex(string mutexId)
+ {
+ return (new Mutex(true, mutexId, out var createdNew), createdNew);
+ }
}