diff --git a/Directory.Build.props b/Directory.Build.props index 86d4457..772abf1 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.3.0 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 8b5af54..1ebffbb 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 8addcb3..a4cee1b 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 8c23a26..2d1edd2 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();