Skip to content

Commit 48aab1e

Browse files
committed
Detect and report inconsistencies in registration lifetimes
A given type may match more than once when using convention registrations (i.e. typeof(IDisposable) and separately, by regex). Ideally there should be no overlap for the same concrete type, as that may cause weird lifetime bugs due to the first one to register the implementation type to "win" (since we use TryAddXXX). So it should be a warning to have a case where this happens. Fixes #115
1 parent 4db4b1b commit 48aab1e

File tree

3 files changed

+112
-10
lines changed

3 files changed

+112
-10
lines changed

src/CodeAnalysis.Tests/ConventionAnalyzerTests.cs

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
using System.Collections.Immutable;
44
using System.IO;
55
using System.Linq;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Devlooped.Extensions.DependencyInjection;
9+
using Microsoft.CodeAnalysis;
810
using Microsoft.CodeAnalysis.CSharp;
911
using Microsoft.CodeAnalysis.CSharp.Testing;
12+
using Microsoft.CodeAnalysis.Diagnostics;
1013
using Microsoft.CodeAnalysis.Testing;
1114
using Xunit;
1215
using Xunit.Abstractions;
@@ -54,7 +57,7 @@ public static void Main()
5457
.AddPackages(ImmutableArray.Create(
5558
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
5659
},
57-
}.WithPreprocessorSymbols();
60+
};
5861

5962
var expected = Verifier.Diagnostic(ConventionsAnalyzer.AssignableTypeOfRequired).WithLocation(0);
6063
test.ExpectedDiagnostics.Add(expected);
@@ -98,7 +101,7 @@ public static void Main()
98101
.AddPackages(ImmutableArray.Create(
99102
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
100103
},
101-
}.WithPreprocessorSymbols();
104+
};
102105

103106
//var expected = Verifier.Diagnostic(ConventionsAnalyzer.AssignableTypeOfRequired).WithLocation(0);
104107
//test.ExpectedDiagnostics.Add(expected);
@@ -145,12 +148,70 @@ public static void Main()
145148
.AddPackages(ImmutableArray.Create(
146149
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
147150
},
148-
}.WithPreprocessorSymbols();
151+
};
149152

150153
var expected = Verifier.Diagnostic(ConventionsAnalyzer.OpenGenericType).WithLocation(0);
151154
test.ExpectedDiagnostics.Add(expected);
152155

153156
await test.RunAsync();
154157
}
155158

156-
}
159+
[Fact]
160+
public async Task WarnIfAmbiguousLifetime()
161+
{
162+
var test = new CSharpSourceGeneratorTest<IncrementalGenerator, DefaultVerifier>
163+
{
164+
TestBehaviors = TestBehaviors.SkipGeneratedSourcesCheck,
165+
TestCode =
166+
"""
167+
using System;
168+
using Microsoft.Extensions.DependencyInjection;
169+
170+
public interface IRepository { }
171+
public class MyRepository : IRepository { }
172+
173+
public static class Program
174+
{
175+
public static void Main()
176+
{
177+
var services = new ServiceCollection();
178+
{|#0:services.AddServices(typeof(IRepository), ServiceLifetime.Scoped)|};
179+
{|#1:services.AddServices("Repository", ServiceLifetime.Singleton)|};
180+
}
181+
}
182+
""",
183+
TestState =
184+
{
185+
AnalyzerConfigFiles =
186+
{
187+
("/.editorconfig",
188+
"""
189+
is_global = true
190+
build_property.AddServicesExtension = true
191+
""")
192+
},
193+
Sources =
194+
{
195+
StaticGenerator.AddServicesExtension,
196+
StaticGenerator.ServiceAttribute,
197+
StaticGenerator.ServiceAttributeT,
198+
},
199+
ReferenceAssemblies = new ReferenceAssemblies(
200+
"net8.0",
201+
new PackageIdentity(
202+
"Microsoft.NETCore.App.Ref", "8.0.0"),
203+
Path.Combine("ref", "net8.0"))
204+
.AddPackages(ImmutableArray.Create(
205+
new PackageIdentity("Microsoft.Extensions.DependencyInjection", "8.0.0")))
206+
},
207+
};
208+
209+
var expected = Verifier.Diagnostic(IncrementalGenerator.AmbiguousLifetime)
210+
.WithArguments("MyRepository", "Scoped, Singleton")
211+
.WithLocation(0).WithLocation(1);
212+
213+
test.ExpectedDiagnostics.Add(expected);
214+
215+
await test.RunAsync();
216+
}
217+
}

src/DependencyInjection.Tests/DependencyInjection.Tests.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
<PackageReference Include="System.Composition.AttributedModel" Version="8.0.0" />
1818
<PackageReference Include="System.Composition.Hosting" Version="8.0.0" />
1919
<PackageReference Include="System.Composition.TypedParts" Version="8.0.0" />
20+
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" GeneratePathProperty="true" />
21+
<PackageReference Include="Microsoft.Bcl.TimeProvider" Version="8.0.1" GeneratePathProperty="true" />
2022
</ItemGroup>
2123

2224
<ItemGroup>
@@ -32,6 +34,11 @@
3234
<Import Project="..\DependencyInjection\Devlooped.Extensions.DependencyInjection.targets" />
3335
<Import Project="..\SponsorLink\SponsorLink.Analyzer.Tests.targets" />
3436

37+
<ItemGroup>
38+
<Analyzer Include="$(PkgMicrosoft_Bcl_HashCode)\lib\netstandard2.0\Microsoft.Bcl.HashCode.dll" />
39+
<Analyzer Include="$(PkgMicrosoft_Bcl_TimeProvider)\lib\netstandard2.0\Microsoft.Bcl.TimeProvider.dll" />
40+
</ItemGroup>
41+
3542
<!-- Force immediate reporting of status, no install-time grace period -->
3643
<PropertyGroup>
3744
<SponsorLinkNoInstallGrace>true</SponsorLinkNoInstallGrace>

src/DependencyInjection/IncrementalGenerator.cs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
using Microsoft.CodeAnalysis;
1010
using Microsoft.CodeAnalysis.CSharp;
1111
using Microsoft.CodeAnalysis.CSharp.Syntax;
12-
using Microsoft.CodeAnalysis.Diagnostics;
1312
using Microsoft.Extensions.DependencyInjection;
1413
using KeyedService = (Microsoft.CodeAnalysis.INamedTypeSymbol Type, Microsoft.CodeAnalysis.TypedConstant? Key);
1514

@@ -22,11 +21,21 @@ namespace Devlooped.Extensions.DependencyInjection;
2221
[Generator(LanguageNames.CSharp)]
2322
public class IncrementalGenerator : IIncrementalGenerator
2423
{
25-
class ServiceSymbol(INamedTypeSymbol type, int lifetime, TypedConstant? key)
24+
public static DiagnosticDescriptor AmbiguousLifetime { get; } =
25+
new DiagnosticDescriptor(
26+
"DDI004",
27+
"Ambiguous lifetime registration.",
28+
"More than one registration matches {0} with lifetimes {1}.",
29+
"Build",
30+
DiagnosticSeverity.Warning,
31+
isEnabledByDefault: true);
32+
33+
class ServiceSymbol(INamedTypeSymbol type, int lifetime, TypedConstant? key, Location? location)
2634
{
2735
public INamedTypeSymbol Type => type;
2836
public int Lifetime => lifetime;
2937
public TypedConstant? Key => key;
38+
public Location? Location => location;
3039

3140
public override bool Equals(object? obj)
3241
{
@@ -42,7 +51,7 @@ public override int GetHashCode()
4251
=> HashCode.Combine(SymbolEqualityComparer.Default.GetHashCode(type), lifetime, key);
4352
}
4453

45-
record ServiceRegistration(int Lifetime, TypeSyntax? AssignableTo, string? FullNameExpression)
54+
record ServiceRegistration(int Lifetime, TypeSyntax? AssignableTo, string? FullNameExpression, Location? Location)
4655
{
4756
Regex? regex;
4857

@@ -175,7 +184,7 @@ bool IsExport(AttributeData attr)
175184
}
176185
}
177186

178-
services.Add(new(x, lifetime, key));
187+
services.Add(new(x, lifetime, key, attr.ApplicationSyntaxReference?.GetSyntax().GetLocation()));
179188
}
180189

181190
return services.ToImmutableArray();
@@ -220,7 +229,7 @@ bool IsExport(AttributeData attr)
220229
if (registration!.FullNameExpression != null && !registration.Regex.IsMatch(typeSymbol.ToFullName(compilation)))
221230
continue;
222231

223-
results.Add(new ServiceSymbol(typeSymbol, registration.Lifetime, null));
232+
results.Add(new ServiceSymbol(typeSymbol, registration.Lifetime, null, registration.Location));
224233
}
225234

226235
return results.ToImmutable();
@@ -259,6 +268,31 @@ void RegisterServicesOutput(IncrementalGeneratorInitializationContext context, I
259268
context.RegisterImplementationSourceOutput(
260269
services.Where(x => x!.Lifetime == 2 && x.Key is not null).Select((x, _) => new KeyedService(x!.Type, x.Key!)).Collect().Combine(compilation),
261270
(ctx, data) => AddPartial("AddKeyedTransient", ctx, data));
271+
272+
context.RegisterImplementationSourceOutput(services.Collect(), ReportInconsistencies);
273+
}
274+
275+
void ReportInconsistencies(SourceProductionContext context, ImmutableArray<ServiceSymbol> array)
276+
{
277+
var grouped = array.GroupBy(x => x.Type, SymbolEqualityComparer.Default).Where(g => g.Count() > 1).ToImmutableArray();
278+
if (grouped.Length == 0)
279+
return;
280+
281+
foreach (var group in grouped)
282+
{
283+
// report if within the group, there are different lifetimes with the same key (or no key)
284+
foreach (var keyed in group.GroupBy(x => x.Key?.Value).Where(g => g.Count() > 1))
285+
{
286+
var lifetimes = string.Join(", ", keyed.Select(x => x.Lifetime).Distinct()
287+
.Select(x => x switch { 0 => "Singleton", 1 => "Scoped", 2 => "Transient", _ => "Unknown" }));
288+
289+
var location = keyed.Where(x => x.Location != null).FirstOrDefault()?.Location;
290+
var otherLocations = keyed.Where(x => x.Location != null).Skip(1).Select(x => x.Location!);
291+
292+
context.ReportDiagnostic(Diagnostic.Create(AmbiguousLifetime,
293+
location, otherLocations, keyed.First().Type.ToDisplayString(), lifetimes));
294+
}
295+
}
262296
}
263297

264298
static string? GetInvokedMethodName(InvocationExpressionSyntax invocation) => invocation.Expression switch
@@ -330,7 +364,7 @@ void RegisterServicesOutput(IncrementalGeneratorInitializationContext context, I
330364

331365
if (assignableTo != null || fullNameExpression != null)
332366
{
333-
return new ServiceRegistration(lifetime, assignableTo, fullNameExpression);
367+
return new ServiceRegistration(lifetime, assignableTo, fullNameExpression, invocation.GetLocation());
334368
}
335369
}
336370
return null;

0 commit comments

Comments
 (0)