From b7437a16c234f40b5197e26dd9a4368e674b3669 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 31 May 2026 07:31:25 -0500 Subject: [PATCH 1/2] Honor per-file ServiceProviderSource override in the CLI codegen paths (wolverine GH-2991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime type loader (DynamicTypeLoader.Initialize/CompileAndAttach) applies a per-ICodeFile service-provider override via file.TryReplaceServiceProvider() -> IServiceVariableSource.ReplaceServiceProvider(), so e.g. a Wolverine HTTP endpoint configured with ServiceProviderSource.FromHttpContextRequestServices resolves its service-located dependencies from httpContext.RequestServices. The CLI codegen paths in DynamicCodeBuilder (write / preview / test) never applied that step, so `dotnet run -- codegen write` generated different (and wrong) code than the runtime: an opaque scoped lambda-factory dependency fell back to a created serviceScope while a sibling DbContext used httpContext.RequestServices — yielding two different scoped instances for one logical request. Fix: apply the override per file in WriteGeneratedCode / generateCode / TryBuildAndCompileAll via a shared applyServiceProviderOverride() helper. Regression guard: unlike the runtime path (transient IServiceVariableSource, fresh per file), the CLI reuses ONE shared source across all files, and ReplaceServiceProvider latches permanently (StartNewMethod only re-creates the default scope when it has not been replaced). So the helper first calls a new IServiceVariableSource.ResetServiceProvider() to undo any prior file's override — otherwise httpContext.RequestServices would leak into every following file (non-HTTP handlers, IsolatedAndScoped endpoints). New DynamicCodeBuilder test asserts the per-file reset+replace sequence isolates the override. Bumps to 2.2.8. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Build.props | 2 +- src/CodegenTests/DynamicCodeBuilderTests.cs | 51 +++++++++++++++++-- .../CodeGeneration/DynamicCodeBuilder.cs | 27 ++++++++++ .../Model/IServiceVariableSource.cs | 7 +++ .../ServiceCollectionServerVariableSource.cs | 13 +++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 86d44579..21eae745 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ JasperFx.Events, JasperFx.Events.SourceGenerator) set $(JasperFxVersion) so they always release together. Bump this one value to release the whole set. --> - 2.2.7 + 2.2.8 13 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 Jeremy D. Miller;Jaedyn Tonee diff --git a/src/CodegenTests/DynamicCodeBuilderTests.cs b/src/CodegenTests/DynamicCodeBuilderTests.cs index 8b5af544..1ebffbb7 100644 --- a/src/CodegenTests/DynamicCodeBuilderTests.cs +++ b/src/CodegenTests/DynamicCodeBuilderTests.cs @@ -83,6 +83,31 @@ public void no_assertion_when_collection_does_not_use_services() recorder.AssertionCalls.ShouldBe(0); } + // #2991: across files sharing one IServiceVariableSource (the CLI codegen path), a per-file + // ServiceProviderSource override (TryReplaceServiceProvider -> true, e.g. HTTP's + // httpContext.RequestServices) must be applied for that file and RESET before the next file so it + // cannot leak into a file that should keep the default isolated-and-scoped provider. + [Fact] + public void per_file_service_provider_override_is_isolated_to_its_own_file() + { + var httpStyle = new RecordingFile("HttpStyle") { ReplacesProvider = true }; + var plain = new RecordingFile("Plain"); + var source = new FakeServiceVariableSource(reportName: null); + + var builder = new DynamicCodeBuilder( + new ServiceCollection().BuildServiceProvider(), + new ICodeFileCollection[] { new RecordingCollection(httpStyle, plain) }) + { + ServiceVariableSource = source + }; + + builder.GenerateAllCode(); + + // Reset runs once per file; ReplaceServiceProvider runs only for the file that opts in — and the + // reset between files prevents the override from leaking into the plain file. + source.Calls.ShouldBe(new[] { "reset", "replace:httpContext.RequestServices", "reset" }); + } + private static DynamicCodeBuilder BuildWithCollection(RecordingFile file, IServiceVariableSource source) { return new DynamicCodeBuilder( @@ -101,6 +126,18 @@ private sealed class RecordingFile : ICodeFile public int AssertionCalls { get; private set; } public ServiceLocationReport[]? LastReports { get; private set; } + // When true, mimics an HTTP endpoint configured with ServiceProviderSource.FromHttpContextRequestServices. + public bool ReplacesProvider { get; init; } + + public bool TryReplaceServiceProvider(out JasperFx.CodeGeneration.Model.Variable serviceProvider) + { + serviceProvider = default!; + if (!ReplacesProvider) return false; + + serviceProvider = new JasperFx.CodeGeneration.Model.Variable(typeof(IServiceProvider), "httpContext.RequestServices"); + return true; + } + public void AssembleTypes(GeneratedAssembly assembly) { assembly.AddType(FileName + "Type", typeof(object)); @@ -121,11 +158,11 @@ public void AssertServiceLocationsAreAllowed(ServiceLocationReport[] reports, IS private sealed class RecordingCollection : ICodeFileCollectionWithServices { - private readonly ICodeFile _file; - public RecordingCollection(ICodeFile file) { _file = file; } + private readonly ICodeFile[] _files; + public RecordingCollection(params ICodeFile[] files) { _files = files; } public string ChildNamespace => "RecordedNamespace"; public GenerationRules Rules { get; } = new("RecordedNamespace"); - public IReadOnlyList BuildFiles() => new[] { _file }; + public IReadOnlyList BuildFiles() => _files; } private sealed class ServicelessCollection : ICodeFileCollection @@ -151,6 +188,14 @@ public void ReplaceVariables(IMethodVariables method) { } public void StartNewType() { } public void StartNewMethod() { } + // #2991: record the per-file override lifecycle so the test can assert isolation. + public List Calls { get; } = new(); + + public void ReplaceServiceProvider(JasperFx.CodeGeneration.Model.Variable serviceProvider) + => Calls.Add($"replace:{serviceProvider.Usage}"); + + public void ResetServiceProvider() => Calls.Add("reset"); + public ServiceLocationReport[] ServiceLocations() { if (_reportName == null) return Array.Empty(); diff --git a/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs b/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs index 8addcb33..a4cee1b8 100644 --- a/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs +++ b/src/JasperFx/CodeGeneration/DynamicCodeBuilder.cs @@ -122,6 +122,11 @@ public void WriteGeneratedCode(Action onFileWritten) var generatedAssembly = collection.StartAssembly(collection.Rules); file.AssembleTypes(generatedAssembly); + // #2991: apply any per-file service-provider override (e.g. HTTP's + // httpContext.RequestServices), matching the runtime DynamicTypeLoader path. + // `services` is shared across every file here, so reset the prior override first. + applyServiceProviderOverride(file, services); + var code = generatedAssembly.GenerateCode(services); // #227: enforce ServiceLocationPolicy per-file, matching the @@ -176,6 +181,10 @@ private string generateCode(ICodeFileCollection collection) throw new CodeGenerationException(file, e); } + // #2991: see WriteGeneratedCode — honor a per-file service-provider override on the + // shared source. + applyServiceProviderOverride(file, services); + var code = generatedAssembly.GenerateCode(services); assertServiceLocationsAllowed(file, services); @@ -208,6 +217,9 @@ public void TryBuildAndCompileAll(Action []; diff --git a/src/JasperFx/CodeGeneration/Services/ServiceCollectionServerVariableSource.cs b/src/JasperFx/CodeGeneration/Services/ServiceCollectionServerVariableSource.cs index 8c23a26b..2d1edd24 100644 --- a/src/JasperFx/CodeGeneration/Services/ServiceCollectionServerVariableSource.cs +++ b/src/JasperFx/CodeGeneration/Services/ServiceCollectionServerVariableSource.cs @@ -102,6 +102,19 @@ public void ReplaceServiceProvider(Variable serviceProvider) _scoped = serviceProvider; } + // GH-2991: the runtime (DynamicTypeLoader) resolves a fresh transient IServiceVariableSource per + // ICodeFile, so a ReplaceServiceProvider() call there is naturally isolated to one file. The CLI + // codegen paths (DynamicCodeBuilder write/preview/test) reuse a SINGLE shared instance across every + // file, and ReplaceServiceProvider latches _replacedServiceProvider = true permanently (StartNewMethod + // only re-creates the default scope when it is false). Reset between files so a per-file + // ServiceProviderSource override (e.g. HTTP's httpContext.RequestServices) does not leak into the + // following files. + public void ResetServiceProvider() + { + _replacedServiceProvider = false; + _scoped = new ScopedContainerCreation().Scoped; + } + public ServiceLocationReport[] ServiceLocations() { return _serviceLocations.ToArray(); From 64dc8fcdad43c97b5505a817b7432bc7ca05226c Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 31 May 2026 07:36:55 -0500 Subject: [PATCH 2/2] Bump to 2.3.0 (new IServiceVariableSource.ResetServiceProvider seam) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 21eae745..772abf1c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ JasperFx.Events, JasperFx.Events.SourceGenerator) set $(JasperFxVersion) so they always release together. Bump this one value to release the whole set. --> - 2.2.8 + 2.3.0 13 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 Jeremy D. Miller;Jaedyn Tonee