diff --git a/docs/guide/extensions.md b/docs/guide/extensions.md index a2826991d..1f3e84633 100644 --- a/docs/guide/extensions.md +++ b/docs/guide/extensions.md @@ -246,10 +246,6 @@ await app.Services.ApplyAsyncWolverineExtensions(); ## Wolverine Plugin Modules -::: warning -This functionality will likely be eliminated in Wolverine 3.0. -::: - ::: tip Use this sparingly, but it might be advantageous for adding extra instrumentation or extra middleware ::: @@ -269,11 +265,45 @@ with this attribute to automatically add that extension to Wolverine: snippet source | anchor +You can also use the non-generic `[assembly: WolverineModule]` form to mark an assembly purely as a *handler* +module — its handlers are discovered, but no `IWolverineExtension` is applied. + +### How discovery works (source generator, not runtime scanning) + +::: tip +Module discovery moved to a compile-time source generator in Wolverine 6.0 (GH-2902). Earlier versions +probed the application's bin directory at startup (`AssemblyFinder`), which was slower and not AOT/trim-friendly. +::: + +The `[WolverineModule]` and `[WolverineModule]` attributes both derive from JasperFx's `[JasperFxAssembly]` +attribute. The **`JasperFx.SourceGenerator`** analyzer — which the `WolverineFx` package flows transitively to +every project that references it (directly or through any Wolverine extension/transport package) — sees these +assembly attributes at **compile time** and emits a small `JasperFx.Generated.DiscoveredExtensions` manifest into +the assembly listing the declared extension type(s). At startup Wolverine reads those generated manifests from the +loaded assemblies instead of scanning the filesystem and speculatively `Assembly.Load`-ing candidates. + +A few consequences worth knowing: + +* **Only the type declared in `[WolverineModule]` (or `[WolverineModule(typeof(T))]`) is auto-applied.** Other + `IWolverineExtension` implementations sitting in the same assembly are *not* activated automatically — register + those explicitly with `opts.Include()` or `services.AddWolverineExtension()`. +* The extension assembly must be **compiled against WolverineFx 6.0+** so that the analyzer runs and emits its + manifest. A module built against an older Wolverine will not be auto-discovered; reference it and call its + configuration explicitly instead. +* Wolverine walks the application's **reference graph** to make sure referenced module assemblies (for example + `WolverineFx.RuntimeCompilation`) are loaded before reading their manifests, so "reference the package and it + just activates" still works. This deliberately does *not* glob the bin directory the way the old scan did. + ## Disabling Assembly Scanning -Some Wolverine users have seen rare issues with the assembly scanning cratering an application with out of memory -exceptions in the case of an application directory being the same as the root of a Docker container. *If* you experience -that issue, or just want a faster start up time, you can disable the automatic extension discovery using this syntax: +::: info +As of Wolverine 6.0 there is no runtime bin-directory assembly scan for extensions — discovery is driven by the +compile-time manifest described above. `ExtensionDiscovery.ManualOnly` remains the switch to turn automatic module +discovery off entirely. +::: + +If you want a marginally faster start up, or you simply want full control over which extensions are applied, you can +disable automatic extension discovery (the reference-graph walk and manifest read) with this syntax: diff --git a/src/Testing/CoreTests/Configuration/extension_discovery_via_manifest.cs b/src/Testing/CoreTests/Configuration/extension_discovery_via_manifest.cs new file mode 100644 index 000000000..51e866feb --- /dev/null +++ b/src/Testing/CoreTests/Configuration/extension_discovery_via_manifest.cs @@ -0,0 +1,58 @@ +using System.Linq; +using JasperFx; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Module1; +using Wolverine.ComplianceTests; +using Wolverine.Tracking; +using Xunit; + +namespace CoreTests.Configuration; + +// Auto-discovery of [WolverineModule] extensions now flows through the compile-time manifest +// emitted by JasperFx.SourceGenerator (the JasperFx.Generated.DiscoveredExtensions class) instead +// of the old runtime ExtensionLoader + AssemblyFinder filesystem scan. See GH-2902. +public class extension_discovery_via_manifest +{ + private static Task startHostAsync() + { + // Default ExtensionDiscovery.Automatic; conventional *handler* discovery is off only to keep + // the host light — it does not affect extension discovery. + return Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.DisableConventionalDiscovery()) + .StartAsync(); + } + + [Fact] + public async Task declared_module_extension_is_discovered_from_the_manifest_and_applied() + { + using var host = await startHostAsync(); + + // Module1 declares [assembly: WolverineModule]; Module1Extension.Configure + // registers IModuleService. Its presence proves the manifest-driven discovery applied it. + host.GetRuntime().Options.AppliedExtensions + .ShouldContain(x => x is Module1Extension); + + host.Services.GetRequiredService() + .HasRegistrationFor().ShouldBeTrue(); + } + + [Fact] + public async Task framework_internal_extensions_are_never_auto_applied() + { + using var host = await startHostAsync(); + var options = host.GetRuntime().Options; + + // The manifest's marker-interface scan also lists Wolverine's own internal IWolverineExtension + // helpers (these are applied explicitly / via DI, never auto-discovered). Discovery is gated to + // each assembly's declared [WolverineModule] type, so none of them may leak in here. Auto-applying + // DisableExternalTransports in particular would silently stub every external transport. + options.ExternalTransportsAreStubbed.ShouldBeFalse(); + + var applied = options.AppliedExtensions.Select(x => x.GetType().Name).ToArray(); + applied.ShouldNotContain("DisableExternalTransports"); + applied.ShouldNotContain("DisablePersistence"); + applied.ShouldNotContain("UseSoloDurabilityMode"); + applied.ShouldNotContain("LambdaWolverineExtension"); + } +} diff --git a/src/Wolverine/ExtensionLoader.cs b/src/Wolverine/ExtensionLoader.cs deleted file mode 100644 index 1610ef709..000000000 --- a/src/Wolverine/ExtensionLoader.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using JasperFx.Core; -using JasperFx.Core.Reflection; -using JasperFx.Core.TypeScanning; -using Wolverine.Attributes; - -namespace Wolverine; - -internal static class ExtensionLoader -{ - private static Assembly[]? _extensions; - private static bool hasWarned = false; - - internal static bool IsModule(Assembly assembly) - { - try - { - if (assembly.HasAttribute()) return true; - } - catch (Exception) - { - if (!hasWarned) - { - Console.WriteLine("To disable automatic Wolverine extension finding, and stop these messages, see:"); - Console.WriteLine("https://wolverinefx.net/guide/extensions.html#disabling-assembly-scanning"); - } - } - - return false; - } - - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "AssemblyFinder.FindAssemblies scans the application base directory for assemblies with [WolverineModule]. AOT-publishing apps should opt out of auto-extension-discovery (see https://wolverinefx.net/guide/extensions.html#disabling-assembly-scanning) and register extensions explicitly.")] - internal static Assembly[] FindExtensionAssemblies() - { - if (_extensions != null) - { - return _extensions; - } - - Action logFailure = msg => - { - if (!hasWarned) - { - Console.WriteLine("To disable automatic Wolverine extension finding, and stop these messages, see:"); - Console.WriteLine("https://wolverinefx.net/guide/extensions.html#disabling-assembly-scanning"); - } - - Console.WriteLine(msg); - }; - - _extensions = AssemblyFinder - .FindAssemblies(logFailure, a => a.HasAttribute(), false) - .Concat(AppDomain.CurrentDomain.GetAssemblies()) - .Distinct() - .Where(a => a.HasAttribute()) - .ToArray(); - - var names = _extensions.Select(x => x.GetName().Name); - - Assembly[] FindDependencies(Assembly a) - { - return _extensions!.Where(x => names.Contains(x.GetName().Name)).ToArray(); - } - - _extensions = _extensions.TopologicalSort(FindDependencies, false).ToArray(); - - return _extensions; - } - - // Activator.CreateInstance(x!) on a Type-typed lambda parameter loses the - // [DAM(PublicConstructors)] annotation that's present on the source field - // (WolverineModuleAttribute.WolverineExtensionType — annotated in chunk G - // #2760). The IL flow analyzer can't track DAM through a Select projection. - // Suppression here is honest: the constructor preservation requirement IS - // met at the source, IL just can't see it. AOT-clean apps register - // extensions explicitly via WolverineOptions.AddExtension and avoid - // assembly scanning entirely (see AOT publishing guide). - [UnconditionalSuppressMessage("Trimming", "IL2067", - Justification = "Constructor preservation requirement is met at the source (WolverineExtensionType is [DAM(PublicConstructors)]); IL flow analyzer can't see through Select projection. AOT consumers register explicitly via WolverineOptions.AddExtension. See AOT guide.")] - internal static void ApplyExtensions(WolverineOptions options) - { - var assemblies = FindExtensionAssemblies(); - - if (assemblies.Length == 0) - { - return; - } - - options.IncludeExtensionAssemblies(assemblies); - - var extensions = assemblies.Select(x => x.GetAttribute()!.WolverineExtensionType) - .Where(x => x != null) - .Select(x => Activator.CreateInstance(x!)!.As()) - .ToArray(); - - options.ApplyExtensions(extensions); - } -} diff --git a/src/Wolverine/HostBuilderExtensions.cs b/src/Wolverine/HostBuilderExtensions.cs index 917a8c69b..c106e5366 100644 --- a/src/Wolverine/HostBuilderExtensions.cs +++ b/src/Wolverine/HostBuilderExtensions.cs @@ -251,7 +251,7 @@ internal static IServiceCollection AddWolverine(this IServiceCollection services options.Services = services; if (discovery == ExtensionDiscovery.Automatic) { - ExtensionLoader.ApplyExtensions(options); + options.DiscoverAndApplyExtensions(); } if (options.ApplicationAssembly != null) diff --git a/src/Wolverine/IWolverineExtension.cs b/src/Wolverine/IWolverineExtension.cs index 01489e3b4..2a1fb7ae8 100644 --- a/src/Wolverine/IWolverineExtension.cs +++ b/src/Wolverine/IWolverineExtension.cs @@ -1,10 +1,12 @@ +using JasperFx; + namespace Wolverine; #region sample_iwolverineextension /// /// Use to create loadable extensions to Wolverine applications /// -public interface IWolverineExtension +public interface IWolverineExtension : IJasperFxExtension { /// /// Make any alterations to the WolverineOptions for the application @@ -19,7 +21,7 @@ public interface IWolverineExtension /// Loadable extensions to Wolverine applications that may require /// IoC services or asynchronous operations /// -public interface IAsyncWolverineExtension +public interface IAsyncWolverineExtension : IJasperFxExtension { ValueTask Configure(WolverineOptions options); } diff --git a/src/Wolverine/Wolverine.csproj b/src/Wolverine/Wolverine.csproj index 7345803ac..5819f852d 100644 --- a/src/Wolverine/Wolverine.csproj +++ b/src/Wolverine/Wolverine.csproj @@ -58,9 +58,17 @@ - + + diff --git a/src/Wolverine/WolverineOptions.Extensions.cs b/src/Wolverine/WolverineOptions.Extensions.cs index 6dd88d7a3..e9ddb3d30 100644 --- a/src/Wolverine/WolverineOptions.Extensions.cs +++ b/src/Wolverine/WolverineOptions.Extensions.cs @@ -1,4 +1,9 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using JasperFx; using JasperFx.Core; +using JasperFx.Core.Reflection; +using Wolverine.Attributes; namespace Wolverine; @@ -7,7 +12,209 @@ public sealed partial class WolverineOptions private readonly IList _extensionTypes = new List(); internal List AppliedExtensions { get; } = []; - + /// + /// Discover and apply types from the compile-time + /// manifest emitted by JasperFx.SourceGenerator (the JasperFx.Generated.DiscoveredExtensions + /// class in each eligible assembly). This replaces the previous runtime assembly scan + /// (ExtensionLoader + the AssemblyFinder.FindAssemblies bin-directory probe). See GH-2902. + /// + internal void DiscoverAndApplyExtensions() + { + // The manifest reader only sees assemblies that are already loaded, but a referenced + // module (e.g. WolverineFx.RuntimeCompilation) may not be loaded yet at bootstrap. Load the + // deployed [WolverineModule] assemblies so "reference the package and it auto-activates" + // still works — using the runtime's resolved deployment list, not the old bin-directory + // glob (Directory.EnumerateFiles) that AssemblyFinder.FindAssemblies used. + ensureReferencedModuleAssembliesAreLoaded(); + + // Include any [WolverineModule]-marked assembly that is now loaded so its handlers + // participate in discovery, exactly as the old ExtensionLoader.IncludeExtensionAssemblies + // path did. This also covers pure handler modules declared with a bare + // [assembly: WolverineModule] that contribute no IWolverineExtension (and so emit no + // manifest entry). + var moduleAssemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && a.HasAttribute()) + .ToArray(); + + if (moduleAssemblies.Length > 0) + { + IncludeExtensionAssemblies(moduleAssemblies); + } + + // Apply the extensions the JasperFx.SourceGenerator ExtensionDiscoveryGenerator found at + // compile time. We keep only the types explicitly declared via [WolverineModule] on + // their own assembly — the exact set the old runtime scan applied. The manifest's + // marker-interface scan also lists framework-internal IWolverineExtension helpers + // (DisableExternalTransports, the ConfigureWolverine() lambda extension, …) that are + // applied explicitly elsewhere and must never be auto-discovered; gating on the declaring + // assembly's declared [WolverineModule] type excludes them. + // + // Applied now — while the IServiceCollection is still mutable — so an extension may still + // register IoC services (e.g. Module1Extension), matching the historical timing. + var extensions = GeneratedExtensionManifest.ReadFromLoadedAssemblies() + .Where(type => type.IsConcrete() && type.CanBeCastTo()) + .Where(isDeclaredModuleExtension) + .Select(buildExtension) + .ToArray(); + + ApplyExtensions(extensions); + } + + // True only when the type is the extension declared by its own assembly's [WolverineModule] + // (or [WolverineModule(typeof(T))]) attribute. This is what restricts manifest discovery to the + // same opt-in set the old ExtensionLoader applied, rather than every IWolverineExtension the + // generator's marker scan happens to list. + private static bool isDeclaredModuleExtension(Type type) + { + return type == type.Assembly.GetAttribute()?.WolverineExtensionType; + } + + // Framework assembly name prefixes we never need to walk into. No Wolverine extension/module + // assembly uses these prefixes, so skipping them keeps the reference-graph walk from loading + // the whole BCL closure. + private static readonly string[] _nonModulePrefixes = + ["System.", "System,", "Microsoft.", "netstandard", "mscorlib", "WindowsBase", "Newtonsoft."]; + + // Make sure every deployed [WolverineModule] assembly is loaded so its source-generated manifest + // can be read below. This intentionally replaces AssemblyFinder.FindAssemblies' + // Directory.EnumerateFiles bin-directory probe. + // + // It does NOT just walk Assembly.GetReferencedAssemblies(): the C# compiler prunes a referenced + // assembly whose types the application never uses directly, so a "reference the package and it + // auto-activates" module like WolverineFx.RuntimeCompilation is absent from the metadata + // reference graph even though it is deployed. Instead we use the runtime's resolved deployment + // list (TRUSTED_PLATFORM_ASSEMBLIES) - not a recursive filesystem scan - which DOES include those + // pruned-but-deployed assemblies. The reference-graph walk is kept as a fallback for hosts that + // don't surface that list (e.g. single-file deployments). + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "Loads assemblies from the runtime's resolved deployment list / declared reference graph (Assembly.Load), not a filesystem probe. AOT/trim-published apps reference their extension assemblies statically and register extensions explicitly (ExtensionDiscovery.ManualOnly); see GH-2902 / AOT guide.")] + private void ensureReferencedModuleAssembliesAreLoaded() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic)) + { + var name = assembly.GetName().Name; + if (name != null) + { + seen.Add(name); + } + } + + if (!tryLoadFromDeploymentList(seen)) + { + walkReferenceGraph(seen); + } + } + + // Load the non-framework assemblies the runtime resolved for this app (the deployment closure), + // which includes referenced module packages whose types the app doesn't use directly. Returns + // false when the host doesn't expose TRUSTED_PLATFORM_ASSEMBLIES so the caller can fall back. + private bool tryLoadFromDeploymentList(HashSet seen) + { + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is not string list || list.Length == 0) + { + return false; + } + + foreach (var path in list.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var name = Path.GetFileNameWithoutExtension(path); + if (name.IsEmpty() || isNonModuleAssembly(name) || !seen.Add(name)) + { + continue; + } + + try + { + Assembly.Load(new AssemblyName(name)); + } + catch (Exception) + { + // Not loadable by simple name (native image, resource/satellite assembly, …); skip. + } + } + + return true; + } + + // Fallback: walk the reference graph from the application assembly plus everything already loaded, + // loading each non-framework referenced assembly. Misses compiler-pruned references, but better + // than nothing when no deployment list is available. + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "Reference-graph fallback (Assembly.GetReferencedAssemblies/Assembly.Load) used only when the runtime deployment list is unavailable. AOT/trim-published apps register extensions explicitly (ExtensionDiscovery.ManualOnly); see GH-2902 / AOT guide.")] + private void walkReferenceGraph(HashSet seen) + { + var queue = new Queue(); + + void enqueue(Assembly assembly) + { + var name = assembly.GetName().Name; + if (name != null && seen.Add(name)) + { + queue.Enqueue(assembly); + } + } + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic).ToArray()) + { + // Already in `seen` from the caller; enqueue for walking without re-adding. + queue.Enqueue(assembly); + } + + if (ApplicationAssembly != null) + { + enqueue(ApplicationAssembly); + } + + while (queue.Count > 0) + { + foreach (var reference in queue.Dequeue().GetReferencedAssemblies()) + { + var name = reference.Name; + if (name == null || seen.Contains(name) || isNonModuleAssembly(name)) + { + continue; + } + + try + { + enqueue(Assembly.Load(reference)); + } + catch (Exception) + { + // A reference we can't resolve can't be a discoverable module; ignore it. + seen.Add(name); + } + } + } + } + + private static bool isNonModuleAssembly(string name) + { + foreach (var prefix in _nonModulePrefixes) + { + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + // The manifest stores extension types as a DAM-less Type[] (typeof(...) literals emitted by the + // generator), so the IL flow analyzer can't see that constructors are preserved. They are: real + // extensions are declared via [WolverineModule], whose attribute constructor takes a + // [DAM(PublicConstructors)] Type, and the generator emits the type with a typeof() literal that + // survives trimming. AOT-clean apps that trim aggressively register extensions explicitly. + [UnconditionalSuppressMessage("Trimming", "IL2067", + Justification = "Extension types come from the source-generated DiscoveredExtensions manifest (typeof literals); public constructors are preserved via the [WolverineModule] attribute's [DAM(PublicConstructors)] Type parameter. See GH-2902 / AOT guide.")] + private static IWolverineExtension buildExtension(Type type) + { + return (IWolverineExtension)Activator.CreateInstance(type)!; + } + + /// /// Applies the extension to this application ///