From 4a31eb57e46e0d764cee876dd127635067e54bd6 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 2 Aug 2026 08:18:52 -0500 Subject: [PATCH] fix: attribute registration to the app, not the extension that registered for it (#601) Follow-up to #600 -- same function, different failure mode. Where #600 was about the walk landing on a test runner, this is about it landing on an intermediate Critter Stack assembly. UseWolverine() and AddMarten() both call services.AddJasperFx() from inside their own assembly, so the first frame outside JasperFx belongs to the EXTENSION, not the application. RegistrationCallingAssembly stopped meaning what its name says, and checkForDivergentApplicationAssembly compared the extension against the correctly-resolved application assembly and raised the GH-3521 warning on hosts where nothing was wrong. That inverts the warning's whole value: it trains readers to ignore it, and its absence stops being evidence. On JasperFx/wolverine#3776 "zero occurrences of the warning in the failing logs" was explicitly recorded as grounds for ruling out an application-assembly problem -- which was exactly the bug. Reproduced here rather than taken on faith: a stand-in extension assembly that calls AddJasperFx() on the app's behalf yields RegistrationCallingAssembly= and ApplicationAssembly=, and the warning FIRES. It is also more than warning noise. The same stack walk seeds the process-wide RememberedApplicationAssembly in AddJasperFx, so wherever IHostEnvironment.ApplicationName is empty the extension assembly became the assembly type discovery scans. Skip Critter Stack framework assemblies in the walk, matched by exact name or dotted prefix so an app named "MartenPlayground" or "WolverineDemo" is left alone, and never matching a *.Tests / *.Testing assembly -- the Critter Stack repos' own suites ARE the application under test, and skipping those would reintroduce #600 from the other side. Co-Authored-By: Claude Opus 5 (1M context) --- jasperfx.slnx | 1 + src/CoreTests/CoreTests.csproj | 1 + src/CoreTests/JasperFxOptionsTests.cs | 94 +++++++++++++++++++ src/ExtensionStandIn/CritterStackExtension.cs | 18 ++++ src/ExtensionStandIn/ExtensionStandIn.csproj | 13 +++ src/JasperFx/JasperFxOptions.cs | 53 ++++++++++- 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/ExtensionStandIn/CritterStackExtension.cs create mode 100644 src/ExtensionStandIn/ExtensionStandIn.csproj diff --git a/jasperfx.slnx b/jasperfx.slnx index bc69704..4869e16 100644 --- a/jasperfx.slnx +++ b/jasperfx.slnx @@ -41,6 +41,7 @@ + diff --git a/src/CoreTests/CoreTests.csproj b/src/CoreTests/CoreTests.csproj index 2d184b5..58b5793 100644 --- a/src/CoreTests/CoreTests.csproj +++ b/src/CoreTests/CoreTests.csproj @@ -28,6 +28,7 @@ + diff --git a/src/CoreTests/JasperFxOptionsTests.cs b/src/CoreTests/JasperFxOptionsTests.cs index 91d9c22..d2b2a9a 100644 --- a/src/CoreTests/JasperFxOptionsTests.cs +++ b/src/CoreTests/JasperFxOptionsTests.cs @@ -1,3 +1,4 @@ +using ExtensionStandIn; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CommandLine.Descriptions; @@ -395,4 +396,97 @@ public void never_adopts_a_test_runner_as_the_calling_assembly() assembly.ShouldNotBeNull(); JasperFxOptions.IsTestRunnerAssembly(assembly.GetName().Name!).ShouldBeFalse(); } + + // GH-601: UseWolverine() and AddMarten() both call services.AddJasperFx() from inside their own + // assembly, so the first frame outside JasperFx belongs to the extension rather than the application. + + [Theory] + [InlineData("JasperFx")] + [InlineData("JasperFx.Events")] + [InlineData("JasperFx.RuntimeCompiler")] + [InlineData("Wolverine")] + [InlineData("Wolverine.SqlServer")] + [InlineData("WolverineFx.RabbitMQ")] + [InlineData("Marten")] + [InlineData("Marten.AspNetCore")] + [InlineData("Weasel.Postgresql")] + [InlineData("Polecat")] + [InlineData("CritterWatch.Server")] + [InlineData("Oakton")] + public void recognizes_critter_stack_framework_assemblies(string assemblyName) + { + JasperFxOptions.IsCritterStackAssembly(assemblyName).ShouldBeTrue(); + } + + [Theory] + // An application is not framework code just because it is named for a product. Matching is by exact + // name or dotted prefix, never a bare StartsWith. + [InlineData("MartenPlayground")] + [InlineData("WolverineDemo")] + [InlineData("JasperFxSamples")] + [InlineData("MyApp")] + [InlineData("CoreTests")] + // And the Critter Stack repos' own test assemblies ARE the application under test -- their handlers + // and documents are the types discovery has to find. + [InlineData("Wolverine.RabbitMQ.Tests")] + [InlineData("Marten.Testing")] + [InlineData("JasperFx.Events.Tests")] + public void does_not_mistake_applications_or_suites_for_framework_assemblies(string assemblyName) + { + JasperFxOptions.IsCritterStackAssembly(assemblyName).ShouldBeFalse(); + } + + [Fact] + public async Task registration_is_attributed_to_the_app_not_the_extension_that_registered_for_it() + { + var original = JasperFxOptions.RememberedApplicationAssembly; + try + { + JasperFxOptions.RememberedApplicationAssembly = null; + + // AddSomeCritterStackTool lives in an assembly named "Wolverine.StackWalkStandIn" and calls + // AddJasperFx() on our behalf, which is exactly what UseWolverine()/AddMarten() do. + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(s => s.AddSomeCritterStackTool()) + .UseEnvironment("Development") + .StartAsync(TestContext.Current.CancellationToken); + + var options = host.Services.GetRequiredService(); + + options.RegistrationCallingAssembly.ShouldBe(GetType().Assembly); + + // ...and because registration is now attributed correctly, the GH-3521 divergence warning + // stays quiet on a host where nothing is actually wrong. + options.ApplicationAssemblyReuseWarning.ShouldBeNull(); + } + finally + { + JasperFxOptions.RememberedApplicationAssembly = original; + } + } + + [Fact] + public void an_extension_registration_does_not_pin_the_extension_as_the_application_assembly() + { + var original = JasperFxOptions.RememberedApplicationAssembly; + try + { + JasperFxOptions.RememberedApplicationAssembly = null; + + // No meaningful IHostEnvironment.ApplicationName here, so establishApplicationAssembly falls + // through to the process-wide pin that AddJasperFx seeded from the same stack walk. Before the + // fix that pin -- and therefore type discovery -- was the extension assembly. + var services = new ServiceCollection(); + services.AddSomeCritterStackTool(); + + var options = services.BuildServiceProvider().GetRequiredService(); + + options.ApplicationAssembly.ShouldBe(GetType().Assembly); + JasperFxOptions.RememberedApplicationAssembly.ShouldBe(GetType().Assembly); + } + finally + { + JasperFxOptions.RememberedApplicationAssembly = original; + } + } } \ No newline at end of file diff --git a/src/ExtensionStandIn/CritterStackExtension.cs b/src/ExtensionStandIn/CritterStackExtension.cs new file mode 100644 index 0000000..3244526 --- /dev/null +++ b/src/ExtensionStandIn/CritterStackExtension.cs @@ -0,0 +1,18 @@ +using JasperFx; +using Microsoft.Extensions.DependencyInjection; + +namespace ExtensionStandIn; + +/// +/// Stands in for a Critter Stack extension in the one respect that matters to +/// JasperFxOptions.DetermineCallingAssembly: it calls AddJasperFx() from inside its own +/// assembly on the application's behalf, exactly the way UseWolverine() and AddMarten() do. +/// This assembly is named "Wolverine.StackWalkStandIn" so the walk sees it as framework code. See GH-601. +/// +public static class CritterStackExtension +{ + public static IServiceCollection AddSomeCritterStackTool(this IServiceCollection services) + { + return services.AddJasperFx(); + } +} diff --git a/src/ExtensionStandIn/ExtensionStandIn.csproj b/src/ExtensionStandIn/ExtensionStandIn.csproj new file mode 100644 index 0000000..7697f9f --- /dev/null +++ b/src/ExtensionStandIn/ExtensionStandIn.csproj @@ -0,0 +1,13 @@ + + + + Wolverine.StackWalkStandIn + false + + + + + + diff --git a/src/JasperFx/JasperFxOptions.cs b/src/JasperFx/JasperFxOptions.cs index 9d547dd..72617c7 100644 --- a/src/JasperFx/JasperFxOptions.cs +++ b/src/JasperFx/JasperFxOptions.cs @@ -241,7 +241,7 @@ private void checkForDivergentApplicationAssembly(Assembly adopted) } if (assemblyName.StartsWith("System") || assemblyName.StartsWith("Microsoft") || - IsTestRunnerAssembly(assemblyName)) + IsTestRunnerAssembly(assemblyName) || IsCritterStackAssembly(assemblyName)) { continue; } @@ -273,6 +273,57 @@ internal static bool IsTestRunnerAssembly(string assemblyName) || assemblyName.StartsWith("NCrunch.", StringComparison.OrdinalIgnoreCase); } + // GH-601: a Critter Stack extension registers JasperFx on the application's behalf -- + // UseWolverine() and AddMarten() both call services.AddJasperFx() from inside their own assembly -- + // so the first frame outside JasperFx belongs to the EXTENSION, not the app. RegistrationCallingAssembly + // then stopped meaning what its name says, and checkForDivergentApplicationAssembly compared the + // extension against the (correctly resolved) application assembly and warned on healthy hosts. That + // inverts the warning's whole value: it trains readers to ignore it, and its absence stops being + // evidence -- on JasperFx/wolverine#3776 "zero occurrences of the warning" was explicitly recorded as + // grounds for ruling out an application-assembly problem, which was exactly the bug. + // + // Matched as an exact name or a dotted prefix rather than a bare StartsWith, so an application named + // "MartenPlayground" or "WolverineDemo" is left alone. + private static readonly string[] _critterStackAssemblies = + [ + "JasperFx", + "Wolverine", + "WolverineFx", + "Marten", + "Weasel", + "Polecat", + "CritterWatch", + "Oakton" + ]; + + internal static bool IsCritterStackAssembly(string assemblyName) + { + // The test assemblies inside the Critter Stack repos themselves -- Wolverine.RabbitMQ.Tests, + // Marten.Testing and friends -- ARE the application as far as discovery is concerned, because the + // handlers and documents under test live in them. Skipping those would reintroduce GH-600's defect + // from the other direction. + if (assemblyName.EndsWith("Tests", StringComparison.OrdinalIgnoreCase) + || assemblyName.EndsWith("Testing", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + foreach (var name in _critterStackAssemblies) + { + if (assemblyName.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (assemblyName.StartsWith(name + ".", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + /// /// Attempts to resolve the project root directory by climbing up from the current path /// looking for .csproj or .sln files. This is useful for Console apps where