From 0e685058dbd6357e364891336ec64ab6bdcc71aa Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 28 May 2026 18:33:44 -0500 Subject: [PATCH 1/2] Fix codegen cycles and nullable-flag input parser (2.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent regressions surfaced when Wolverine 6.2.0 and Marten 9.3.0 (both depending on JasperFx 2.2.0) run against a host that exercises the inline IEnumerable codegen path. Together they prevent host startup; each contributes one stack-overflow or compile failure. 1. InputParserGenerator emits a `Func` for any flag type that falls through the primitive switch — notably nullable primitives like `int?` and `DateTime?`. `GeneratedFlag` takes `Func`, so the generated parser fails to compile with CS1503. Mirror the cast wrapper that GetElementConverterExpression already uses: `s => ({T})...FindConverter(typeof({T}))!(s)`. Existing primitive fast paths are unchanged. 2. DependencyGatherer's two co-recursive yield iterators have no cycle protection — Frame.Uses → Variables[v] → Variable.Creator → Dependencies[creator] can loop, and even acyclic-but-deep graphs (the Wolverine/Marten WolverineRuntime constructor service graph) blow the stack at ~250 levels. Replace both iterators with a single iterative BFS that walks the (Frame, Variable) closure once with HashSet-tracked visited nodes. The original walk had a side effect of populating `Variables` cache keys that MethodFrameArranger. findInjectedFields reads via `Variables.Keys()`; preserve that by `Fill`-ing each visited Variable as the BFS sees it, otherwise generated handler classes lose their injected field declarations and their method bodies reference identifiers that aren't declared (CS0103). 3. EnumerableSingletons.KeyedMirror's factory was `(sp, _) => sp.GetServices(elementType).ElementAt(ordinal)`. When a container inlines the singleton element of a mixed-lifetime IEnumerable via QuickResolve (Lamar does this for any InjectedServiceField targeting a Singleton), the factory re-enters the same IEnumerable resolution while one of its elements is being built — infinite recursion that stack-overflows in ~750 nested ListAssignmentFrame.WriteExpressions / QuickResolve frames before any handler runs. Bind directly to the source ServiceDescriptor's ImplementationInstance / ImplementationFactory / ImplementationType+ActivatorUtilities so the lookup never round-trips through GetServices. Updated ArrayPlan and EnumerableSingletonRegistrationExtensions to pass the source descriptor through. Bumps JasperFxVersion 2.2.0 → 2.2.1 (Fix). JasperFx.RuntimeCompiler is independently versioned and unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- .../InputParserGenerator.cs | 2 +- .../CodeGeneration/DependencyGatherer.cs | 96 ++++++++++++++----- .../CodeGeneration/Services/ArrayPlan.cs | 2 +- .../Services/EnumerableSingletons.cs | 36 ++++++- ...umerableSingletonRegistrationExtensions.cs | 2 +- 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index ecdb6255..5faf412e 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.0 + 2.2.1 13 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 Jeremy D. Miller;Jaedyn Tonee diff --git a/src/JasperFx.SourceGenerator/InputParserGenerator.cs b/src/JasperFx.SourceGenerator/InputParserGenerator.cs index f7fa43b2..74bbd6b4 100644 --- a/src/JasperFx.SourceGenerator/InputParserGenerator.cs +++ b/src/JasperFx.SourceGenerator/InputParserGenerator.cs @@ -408,7 +408,7 @@ private static string GetConverterExpression(MemberParseInfo member) "global::System.Guid" => "s => global::System.Guid.Parse(s)", "global::System.DateTime" => "s => global::JasperFx.CommandLine.Internal.Conversion.DateTimeConverter.GetDateTime(s)", "global::System.TimeSpan" => "s => global::JasperFx.CommandLine.Internal.Conversion.TimeSpanConverter.GetTimeSpan(s)", - _ => $"new global::JasperFx.CommandLine.Internal.Conversion.Conversions().FindConverter(typeof({type}))!", + _ => $"s => ({type})new global::JasperFx.CommandLine.Internal.Conversion.Conversions().FindConverter(typeof({type}))!(s)", }; } diff --git a/src/JasperFx/CodeGeneration/DependencyGatherer.cs b/src/JasperFx/CodeGeneration/DependencyGatherer.cs index 18a76f30..be2668a6 100644 --- a/src/JasperFx/CodeGeneration/DependencyGatherer.cs +++ b/src/JasperFx/CodeGeneration/DependencyGatherer.cs @@ -14,39 +14,87 @@ internal class DependencyGatherer public DependencyGatherer(IMethodVariables methodVariables, IList frames) { _methodVariables = methodVariables; - Dependencies.OnMissing = frame => new List(findDependencies(frame).Distinct()); - Variables.OnMissing = v => new List(findDependencies(v).Distinct()); + Dependencies.OnMissing = frame => + { + // Iterative BFS replaces the original co-recursive yield iterators that lacked cycle + // protection and stack-overflowed on deep Frame/Variable graphs (e.g. Lamar/Wolverine's + // resolver code-gen). Variables encountered along the way are pinned into the Variables + // cache via Fill so MethodFrameArranger.findInjectedFields (which reads Variables.Keys()) + // observes the same population the original recursive walk produced as a side effect. + var result = new List(); + var seenFrames = new HashSet { frame }; + var seenVariables = new HashSet(); + Walk(frame, null, result, seenFrames, seenVariables, top: frame); + return result; + }; + Variables.OnMissing = v => + { + var result = new List(); + var seenFrames = new HashSet(); + var seenVariables = new HashSet { v }; + Walk(null, v, result, seenFrames, seenVariables, top: null); + return result; + }; foreach (var frame in frames) Dependencies.FillDefault(frame); } - - private IEnumerable findDependencies(Frame frame) + private void Walk(Frame? startFrame, Variable? startVariable, List result, + HashSet seenFrames, HashSet seenVariables, Frame? top) { - frame.ResolveVariables(_methodVariables); + var frameQueue = new Queue(); + var variableQueue = new Queue(); + if (startFrame != null) frameQueue.Enqueue(startFrame); + if (startVariable != null) variableQueue.Enqueue(startVariable); - foreach (var dependency in frame.Dependencies) + while (frameQueue.Count > 0 || variableQueue.Count > 0) { - yield return dependency; + while (frameQueue.Count > 0) + { + var f = frameQueue.Dequeue(); + f.ResolveVariables(_methodVariables); - foreach (var child in Dependencies[dependency]) yield return child; - } + foreach (var dep in f.Dependencies) + { + if (seenFrames.Add(dep)) + { + if (!ReferenceEquals(dep, top)) result.Add(dep); + frameQueue.Enqueue(dep); + } + } - foreach (var variable in frame.Uses) - foreach (var dependency in Variables[variable]) - yield return dependency; - } + foreach (var v in f.Uses) + { + if (seenVariables.Add(v)) + { + // Pin the key so findInjectedFields' Variables.Keys() lookup sees it. The + // value here is a placeholder; the next caller to read Variables[v] + // through the indexer triggers a fresh BFS via OnMissing. + Variables.Fill(v, EmptyList); + variableQueue.Enqueue(v); + } + } + } - private IEnumerable findDependencies(Variable variable) - { - if (variable.Creator != null) - { - yield return variable.Creator; - foreach (var frame in Dependencies[variable.Creator]) yield return frame; - } + if (variableQueue.Count == 0) break; + var variable = variableQueue.Dequeue(); + + if (variable.Creator != null && seenFrames.Add(variable.Creator)) + { + if (!ReferenceEquals(variable.Creator, top)) result.Add(variable.Creator); + frameQueue.Enqueue(variable.Creator); + } - foreach (var dependency in variable.Dependencies) - foreach (var frame in Variables[dependency]) - yield return frame; + foreach (var d in variable.Dependencies) + { + if (seenVariables.Add(d)) + { + Variables.Fill(d, EmptyList); + variableQueue.Enqueue(d); + } + } + } } -} \ No newline at end of file + + private static readonly List EmptyList = new(0); +} diff --git a/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs b/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs index 4cf2a9be..cdd06401 100644 --- a/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs +++ b/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs @@ -45,7 +45,7 @@ public ArrayFamily(Type serviceType) : base(serviceType, [new ServiceDescriptor( { var plan = plans[i]; elementPlans.Add(plan.Lifetime == ServiceLifetime.Singleton - ? new SingletonPlan(EnumerableSingletons.KeyedMirror(ElementType, i)) + ? new SingletonPlan(EnumerableSingletons.KeyedMirror(ElementType, i, plan.Descriptor)) : plan); } diff --git a/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs b/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs index 85549d79..edd80f7c 100644 --- a/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs +++ b/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs @@ -43,12 +43,38 @@ public static string MissingMirrorMessage(Type elementType, object? key) => /// /// A keyed singleton descriptor that forwards to the -th /// non-keyed registration of , sharing that registration's - /// singleton instance (GetServices honors each registration's own lifetime). + /// singleton instance. + /// + /// The factory binds directly to (its , + /// , or ) + /// rather than calling sp.GetServices(elementType). The latter would re-enter the same + /// IEnumerable<T> resolution while the container is inlining the singleton element of + /// that enumerable, creating infinite recursion in code-gen containers that inline singleton values + /// (e.g. Lamar). Binding to the source descriptor short-circuits that cycle. + /// /// - public static ServiceDescriptor KeyedMirror(Type elementType, int nonKeyedOrdinal) + public static ServiceDescriptor KeyedMirror(Type elementType, int nonKeyedOrdinal, ServiceDescriptor source) { - var ordinal = nonKeyedOrdinal; - return new ServiceDescriptor(elementType, KeyFor(ordinal), - (sp, _) => sp.GetServices(elementType).ElementAt(ordinal)!, ServiceLifetime.Singleton); + if (source.ImplementationInstance is not null) + { + var instance = source.ImplementationInstance; + return new ServiceDescriptor(elementType, KeyFor(nonKeyedOrdinal), + (_, _) => instance, ServiceLifetime.Singleton); + } + + if (source.ImplementationFactory is not null) + { + var factory = source.ImplementationFactory; + return new ServiceDescriptor(elementType, KeyFor(nonKeyedOrdinal), + (sp, _) => factory(sp), ServiceLifetime.Singleton); + } + + var implementationType = source.ImplementationType + ?? throw new InvalidOperationException( + $"Source ServiceDescriptor for {elementType.FullNameInCode()} (ordinal {nonKeyedOrdinal}) " + + "has neither an implementation instance, factory, nor type."); + + return new ServiceDescriptor(elementType, KeyFor(nonKeyedOrdinal), + (sp, _) => ActivatorUtilities.CreateInstance(sp, implementationType), ServiceLifetime.Singleton); } } diff --git a/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs b/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs index b0f1faf0..cca55b4e 100644 --- a/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs +++ b/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs @@ -56,7 +56,7 @@ public static IServiceCollection AddJasperFxEnumerableSingletonSupport(this ISer if (!alreadyRegistered) { - toAdd.Add(EnumerableSingletons.KeyedMirror(family.Key, i)); + toAdd.Add(EnumerableSingletons.KeyedMirror(family.Key, i, nonKeyed[i])); } } } From f675fefa3d59bd130bcc9cfe74b99928bec788b7 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 28 May 2026 19:11:40 -0500 Subject: [PATCH 2/2] Revert KeyedMirror source-descriptor rebind (preserve singleton sharing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KeyedMirror change in 0e68505 made the keyed mirror's factory bind directly to the source ServiceDescriptor's ImplementationInstance / ImplementationFactory / ImplementationType. That broke CodegenTests.Services.inline_enumerable_with_mixed_lifetimes (`two_singletons_among_mixed_are_each_shared`, `singleton_element_is_the_shared_container_singleton`): for sources registered as `AddSingleton()` or `AddSingleton(factory)`, the rebound factory built a fresh instance instead of returning the container's cached non-keyed singleton, so the keyed mirror no longer mirrored. The original `sp.GetServices(elementType).ElementAt(ordinal)` factory is necessary to preserve sharing — the container's cached singleton only flows through GetServices. Lamar's IEnumerable codegen cycle (the original motivation for the rebind) is rooted in Lamar's `ServiceFamily` building one family per service type with keyed and non-keyed instances combined: when KeyedMirror's factory calls `sp.GetServices(T)`, Lamar's `ListInstance.Elements` includes the keyed mirror itself and Lamar re-inlines it via `InjectedServiceField.ToVariableExpression`'s QuickResolve. MS DI's `GetServices` excludes keyed registrations, so the cycle is Lamar-specific. The right fix is in Lamar (or a filter applied via JasperFx that Lamar consults), not in KeyedMirror — defer to a follow-up. This commit keeps the InputParserGenerator and DependencyGatherer fixes from 0e68505 and the 2.2.0 → 2.2.1 version bump. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CodeGeneration/Services/ArrayPlan.cs | 2 +- .../Services/EnumerableSingletons.cs | 36 +++---------------- ...umerableSingletonRegistrationExtensions.cs | 2 +- 3 files changed, 7 insertions(+), 33 deletions(-) diff --git a/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs b/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs index cdd06401..4cf2a9be 100644 --- a/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs +++ b/src/JasperFx/CodeGeneration/Services/ArrayPlan.cs @@ -45,7 +45,7 @@ public ArrayFamily(Type serviceType) : base(serviceType, [new ServiceDescriptor( { var plan = plans[i]; elementPlans.Add(plan.Lifetime == ServiceLifetime.Singleton - ? new SingletonPlan(EnumerableSingletons.KeyedMirror(ElementType, i, plan.Descriptor)) + ? new SingletonPlan(EnumerableSingletons.KeyedMirror(ElementType, i)) : plan); } diff --git a/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs b/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs index edd80f7c..85549d79 100644 --- a/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs +++ b/src/JasperFx/CodeGeneration/Services/EnumerableSingletons.cs @@ -43,38 +43,12 @@ public static string MissingMirrorMessage(Type elementType, object? key) => /// /// A keyed singleton descriptor that forwards to the -th /// non-keyed registration of , sharing that registration's - /// singleton instance. - /// - /// The factory binds directly to (its , - /// , or ) - /// rather than calling sp.GetServices(elementType). The latter would re-enter the same - /// IEnumerable<T> resolution while the container is inlining the singleton element of - /// that enumerable, creating infinite recursion in code-gen containers that inline singleton values - /// (e.g. Lamar). Binding to the source descriptor short-circuits that cycle. - /// + /// singleton instance (GetServices honors each registration's own lifetime). /// - public static ServiceDescriptor KeyedMirror(Type elementType, int nonKeyedOrdinal, ServiceDescriptor source) + public static ServiceDescriptor KeyedMirror(Type elementType, int nonKeyedOrdinal) { - if (source.ImplementationInstance is not null) - { - var instance = source.ImplementationInstance; - return new ServiceDescriptor(elementType, KeyFor(nonKeyedOrdinal), - (_, _) => instance, ServiceLifetime.Singleton); - } - - if (source.ImplementationFactory is not null) - { - var factory = source.ImplementationFactory; - return new ServiceDescriptor(elementType, KeyFor(nonKeyedOrdinal), - (sp, _) => factory(sp), ServiceLifetime.Singleton); - } - - var implementationType = source.ImplementationType - ?? throw new InvalidOperationException( - $"Source ServiceDescriptor for {elementType.FullNameInCode()} (ordinal {nonKeyedOrdinal}) " + - "has neither an implementation instance, factory, nor type."); - - return new ServiceDescriptor(elementType, KeyFor(nonKeyedOrdinal), - (sp, _) => ActivatorUtilities.CreateInstance(sp, implementationType), ServiceLifetime.Singleton); + var ordinal = nonKeyedOrdinal; + return new ServiceDescriptor(elementType, KeyFor(ordinal), + (sp, _) => sp.GetServices(elementType).ElementAt(ordinal)!, ServiceLifetime.Singleton); } } diff --git a/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs b/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs index cca55b4e..b0f1faf0 100644 --- a/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs +++ b/src/JasperFx/EnumerableSingletonRegistrationExtensions.cs @@ -56,7 +56,7 @@ public static IServiceCollection AddJasperFxEnumerableSingletonSupport(this ISer if (!alreadyRegistered) { - toAdd.Add(EnumerableSingletons.KeyedMirror(family.Key, i, nonKeyed[i])); + toAdd.Add(EnumerableSingletons.KeyedMirror(family.Key, i)); } } }