Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 107 additions & 56 deletions Source/Awaiten.SourceGenerators/AwaitenGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,14 +454,24 @@ private static Dictionary<int, List<int>> BuildDependencyGraph(
}
}

// A factory's parameters resolve from the graph exactly like a constructor's.
List<ParameterModel> parameters = ClassifyParameters(producer, info, serviceToImpl, diagnostics);

// Disposability follows the type the container actually owns: a factory's concrete return type
// (which may implement IDisposable behind a non-disposable service interface), or the constructed
// implementation type. Using info.Symbol for a factory would miss a DisposableX behind an IX and
// leak it.
ITypeSymbol disposalType = info.Production == ProductionKind.Factory ? producer.ReturnType : info.Symbol;
// An asynchronous factory returns Task<T> / ValueTask<T>: the container awaits it, so the type it
// actually owns is the awaited result T, not the Task. A synchronous factory owns its return type
// directly, and a constructed implementation owns info.Symbol.
bool asyncFactory = info.Production == ProductionKind.Factory
&& ContainerRegistrations.IsAsyncFactoryReturn(producer.ReturnType, compilation, out _);

// A factory's parameters resolve from the graph exactly like a constructor's. An async factory
// additionally forwards the resolve-time CancellationToken (the async creator's) into a matching
// parameter rather than resolving it from the graph.
List<ParameterModel> parameters = ClassifyParameters(producer, info, asyncFactory, serviceToImpl, diagnostics);

// Disposability follows the type the container actually owns: a factory's produced type (which may
// implement IDisposable behind a non-disposable service interface; for an async factory this is the
// awaited T, not the Task), or the constructed implementation type. Using info.Symbol for a factory
// would miss a DisposableX behind an IX and leak it.
ITypeSymbol disposalType = info.Production == ProductionKind.Factory
? ContainerRegistrations.ProducedType(producer.ReturnType, compilation)
: info.Symbol;
bool disposable = disposableSymbol is not null && ImplementsInterface(disposalType, disposableSymbol);

// A factory's declared return type can hide a concrete IDisposable behind a non-disposable service
Expand All @@ -478,7 +488,10 @@ private static Dictionary<int, List<int>> BuildDependencyGraph(
// Async initialization follows the type the container actually owns - a factory's concrete return type
// (which may implement IAsyncInitializable behind a non-async service interface) or the constructed
// implementation type - mirroring the disposal-type choice above. A pre-built Instance is returned
// early above and is never initialized here (the caller owns it).
// early above and is never initialized here (the caller owns it). An async factory is async-tainted
// regardless of whether its produced type implements IAsyncInitializable: its result is reached only by
// awaiting the Task (see the IsAsyncFactory seed in PropagateAsyncTaint). When the produced type IS
// IAsyncInitializable, the container additionally awaits its InitializeAsync after the factory completes.
bool asyncInit = asyncInitializableSymbol is not null && ImplementsInterface(disposalType, asyncInitializableSymbol);

return new InstanceModel(
Expand All @@ -492,6 +505,7 @@ private static Dictionary<int, List<int>> BuildDependencyGraph(
info.Production,
info.ProductionMember,
asyncInit,
IsAsyncFactory: asyncFactory,
RuntimeDisposalCheck: runtimeDisposalCheck);

static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface)
Expand Down Expand Up @@ -519,16 +533,20 @@ static bool CouldHideDisposable(ITypeSymbol type)
private static List<ParameterModel> ClassifyParameters(
IMethodSymbol producer,
ImplInfo info,
bool asyncFactory,
Dictionary<ServiceKey, string> serviceToImpl,
List<DiagnosticInfo> diagnostics)
{
List<ParameterModel> parameters = new();
foreach (IParameterSymbol parameter in producer.Parameters)
{
ParameterModel parameterModel = ClassifyParameter(parameter);
ParameterModel parameterModel = ClassifyParameter(parameter, asyncFactory);
parameters.Add(parameterModel);

if (parameterModel.Kind != DependencyKind.Arg && !serviceToImpl.ContainsKey(KeyOf(parameterModel)))
// A CancellationToken is forwarded from the resolve-time token, not resolved from the graph (like
// [Arg]), so it is never a missing dependency.
if (parameterModel.Kind is not (DependencyKind.Arg or DependencyKind.CancellationToken)
&& !serviceToImpl.ContainsKey(KeyOf(parameterModel)))
{
diagnostics.Add(new DiagnosticInfo(
Diagnostics.MissingDependency,
Expand Down Expand Up @@ -617,7 +635,8 @@ private static void ValidateInstanceMember(
IMethodSymbol? resolvable = constructors
.Where(c => c.Parameters.All(p =>
{
ParameterModel parameter = ClassifyParameter(p);
// Selecting a constructor, never an async factory, so no CancellationToken forwarding applies.
ParameterModel parameter = ClassifyParameter(p, asyncFactory: false);
return parameter.Kind == DependencyKind.Arg || registered.Contains(parameter.ServiceType);
}))
.OrderByDescending(c => c.Parameters.Length)
Expand Down Expand Up @@ -648,7 +667,7 @@ static bool IsAccessibleConstructor(IMethodSymbol constructor, INamedTypeSymbol
/// (e.g. <c>Func&lt;Func&lt;T&gt;&gt;</c>) is classified as a direct dependency so it surfaces as an
/// unregistered service type rather than a misleading diagnostic about the inner relationship.
/// </summary>
private static ParameterModel ClassifyParameter(IParameterSymbol parameter)
private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool asyncFactory)
{
LocationInfo? location = LocationInfo.From(parameter.Locations.FirstOrDefault());

Expand All @@ -657,6 +676,20 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter)
return new ParameterModel(parameter.Type.ToDisplayString(FullyQualified), DependencyKind.Arg, Location: location);
}

// An asynchronous factory's CancellationToken parameter is not resolved from the graph: the container
// forwards the resolve-time token (the async creator's cancellationToken). Limited to async factories -
// only they are constructed on the async path where that token exists; a synchronous factory (or a
// constructor) has no ambient token to forward, so its CancellationToken stays an ordinary dependency
// and is reported as AWT101 when unregistered rather than silently receiving default. An [Arg]
// CancellationToken is handled above as a caller-supplied runtime argument and is left untouched.
if (asyncFactory
&& parameter.Type is INamedTypeSymbol { Name: "CancellationToken", } token
&& token.ContainingNamespace?.ToDisplayString() == "System.Threading")
{
return new ParameterModel(
parameter.Type.ToDisplayString(FullyQualified), DependencyKind.CancellationToken, Location: location);
}

// A [FromKey] selects the keyed registration of the dependency's service type, whether it is required
// directly, deferred behind a Func<T>/Lazy<T>, or wrapped in an Owned<T> handle - the service type is
// the same, only the delivery differs.
Expand All @@ -669,45 +702,60 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter)
}

if (parameter.Type is INamedTypeSymbol { IsGenericType: true, } named
&& named.ContainingNamespace?.ToDisplayString() == "System")
&& named.ContainingNamespace?.ToDisplayString() == "System"
&& ClassifyRelationship(named, key, location) is { } relationship)
{
if (named is { Name: "Lazy", TypeArguments.Length: 1, } && !IsRelationshipType(named.TypeArguments[0]))
{
return new ParameterModel(
named.TypeArguments[0].ToDisplayString(FullyQualified), DependencyKind.Lazy, Key: key, Location: location);
}

if (named is { Name: "Func", TypeArguments.Length: >= 1, })
{
// Func<T> defers resolution; Func<TArg…, T> additionally supplies runtime arguments (the
// leading type arguments) to the produced service's [Arg]-marked parameters.
ITypeSymbol[] typeArgs = named.TypeArguments.ToArray();
ITypeSymbol service = typeArgs[typeArgs.Length - 1];
string[] argTypes = typeArgs.Take(typeArgs.Length - 1)
.Select(t => t.ToDisplayString(FullyQualified))
.ToArray();

// Func<…, Owned<T>> is the leak-free factory: its produced value is an Owned<T> disposal handle.
if (IsOwned(service, out ITypeSymbol funcOwnedInner))
{
return new ParameterModel(
funcOwnedInner.ToDisplayString(FullyQualified), DependencyKind.Func,
new EquatableArray<string>(argTypes), Key: key, Location: location, ProducesOwned: true);
}

if (!IsRelationshipType(service))
{
return new ParameterModel(
service.ToDisplayString(FullyQualified), DependencyKind.Func, new EquatableArray<string>(argTypes), Key: key, Location: location);
}
}
return relationship;
}

// A direct dependency, optionally selecting a keyed registration with [FromKey].
return new ParameterModel(
parameter.Type.ToDisplayString(FullyQualified), DependencyKind.Direct, Key: key, Location: location);
}

/// <summary>
/// Classifies a <c>System</c> generic as the single-level relationship it defers - <c>Lazy&lt;T&gt;</c>,
/// <c>Func&lt;T&gt;</c> or <c>Func&lt;TArg…, T&gt;</c> (the latter optionally producing an
/// <c>Owned&lt;T&gt;</c> disposal handle) - returning the underlying service type. A type that is not a
/// recognized relationship, or whose produced type is itself a relationship (nesting beyond one level),
/// returns <see langword="null" /> so the caller treats it as a direct dependency on the whole type.
/// </summary>
private static ParameterModel? ClassifyRelationship(INamedTypeSymbol named, string? key, LocationInfo? location)
{
if (named is { Name: "Lazy", TypeArguments.Length: 1, } && !IsRelationshipType(named.TypeArguments[0]))
{
return new ParameterModel(
named.TypeArguments[0].ToDisplayString(FullyQualified), DependencyKind.Lazy, Key: key, Location: location);
}

if (named is not { Name: "Func", TypeArguments.Length: >= 1, })
{
return null;
}

// Func<T> defers resolution; Func<TArg…, T> additionally supplies runtime arguments (the leading type
// arguments) to the produced service's [Arg]-marked parameters.
ITypeSymbol[] typeArgs = named.TypeArguments.ToArray();
ITypeSymbol service = typeArgs[typeArgs.Length - 1];
string[] argTypes = typeArgs.Take(typeArgs.Length - 1)
.Select(t => t.ToDisplayString(FullyQualified))
.ToArray();

// Func<…, Owned<T>> is the leak-free factory: its produced value is an Owned<T> disposal handle.
if (IsOwned(service, out ITypeSymbol funcOwnedInner))
{
return new ParameterModel(
funcOwnedInner.ToDisplayString(FullyQualified), DependencyKind.Func,
new EquatableArray<string>(argTypes), Key: key, Location: location, ProducesOwned: true);
}

// Func<…, T> over a relationship type (nesting beyond one level) falls through to a direct dependency.
return IsRelationshipType(service)
? null
: new ParameterModel(
service.ToDisplayString(FullyQualified), DependencyKind.Func, new EquatableArray<string>(argTypes), Key: key, Location: location);
}

private static ServiceKey KeyOf(ParameterModel parameter) => new(parameter.ServiceType, parameter.Key);

private static string DisplayKeyed(string serviceType, string? key)
Expand Down Expand Up @@ -782,16 +830,18 @@ internal static bool ReadSyncResolveAfterInit(INamedTypeSymbol containerSymbol)
}

/// <summary>
/// Marks every instance that is async-initialized or that reaches one through non-deferred (Direct)
/// edges, by fixpoint over the dependency graph. The edges already exclude relationship/Owned/Arg
/// parameters, so the taint is laundered by exactly the deferrals that break cycles.
/// Marks every instance that is an async-taint source - its implementation is async-initialized, or it
/// is produced by an asynchronous factory (Task&lt;T&gt; / ValueTask&lt;T&gt;), which the container can
/// only reach by awaiting - or that reaches one through non-deferred (Direct) edges, by fixpoint over
/// the dependency graph. The edges already exclude relationship/Owned/Arg parameters, so the taint is
/// laundered by exactly the deferrals that break cycles.
/// </summary>
private static bool[] PropagateAsyncTaint(List<InstanceModel> instances, Dictionary<int, List<int>> dependencies)
{
bool[] tainted = new bool[instances.Count];
for (int i = 0; i < instances.Count; i++)
{
tainted[i] = instances[i].IsAsyncInitializable;
tainted[i] = instances[i].IsAsyncInitializable || instances[i].IsAsyncFactory;
}

bool changed = true;
Expand Down Expand Up @@ -859,7 +909,7 @@ private static void DetectSynchronousAsyncResolution(

// Point the diagnostic at the offending parameter; fall back to the consumer's registration.
LocationInfo? location = parameter.Location ?? instanceLocations[i];
if (instances[target].IsAsyncInitializable)
if (instances[target].IsAsyncSource)
{
diagnostics.Add(new DiagnosticInfo(
Diagnostics.SynchronousAsyncResolution,
Expand Down Expand Up @@ -899,7 +949,7 @@ private static string AsyncTaintPath(List<InstanceModel> instances, Dictionary<i
while (queue.Count > 0)
{
int node = queue.Dequeue();
if (instances[node].IsAsyncInitializable)
if (instances[node].IsAsyncSource)
{
end = node;
break;
Expand Down Expand Up @@ -998,12 +1048,13 @@ private static void ValidateRuntimeArguments(
}

// A parameterized service is reachable only through a synchronous Func<TArg…, T>, which returns the
// service directly and so cannot await InitializeAsync. Combining [Arg] with IAsyncInitializable
// would therefore either hand back an uninitialized instance (SyncResolveAfterInit) or be silently
// unreachable (strict) - neither has a correct resolution path until an async parameterized factory
// exists. Reported in both modes (it is not a sync-vs-async resolution choice but an unsupported
// combination).
if (instance.IsParameterized && instance.IsAsyncInitializable)
// service directly and so cannot await it. Combining [Arg] with an async-taint source (an
// IAsyncInitializable implementation, or an asynchronous Task<T> / ValueTask<T> factory) would
// therefore either hand back an uninitialized/unawaited instance (SyncResolveAfterInit) or be
// silently unreachable (strict) - neither has a correct resolution path until an async parameterized
// factory relationship (Func<TArg…, Task<T>>) exists. Reported in both modes (it is not a
// sync-vs-async resolution choice but an unsupported combination).
if (instance.IsParameterized && instance.IsAsyncSource)
{
diagnostics.Add(new DiagnosticInfo(
Diagnostics.ParameterizedAsyncInitialization,
Expand Down
45 changes: 41 additions & 4 deletions Source/Awaiten.SourceGenerators/ContainerRegistrations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,11 @@ static bool IsAccessibleFromDerived(ISymbol member, INamedTypeSymbol container)

/// <summary>
/// The ordinary methods named <paramref name="name" /> on the container (or an accessible base
/// type) whose return type is implicitly convertible to <paramref name="serviceType" /> - the
/// candidate factory methods for a <c>Factory</c> registration. None means AWT108; more than one
/// means an ambiguous factory (AWT112).
/// type) whose return type produces <paramref name="serviceType" /> - the candidate factory methods
/// for a <c>Factory</c> registration. A synchronous factory's return type is implicitly convertible to
/// the service type; an asynchronous factory returns <c>Task&lt;T&gt;</c> / <c>ValueTask&lt;T&gt;</c>
/// and is matched against the unwrapped <c>T</c> (the container awaits it). None means AWT108; more
/// than one means an ambiguous factory (AWT112).
/// </summary>
public static List<IMethodSymbol> FindFactoryCandidates(
INamedTypeSymbol container, string name, ITypeSymbol serviceType, Compilation compilation)
Expand All @@ -168,12 +170,47 @@ public static List<IMethodSymbol> FindFactoryCandidates(
foreach (ISymbol member in AccessibleMembers(container, name))
{
if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary, } method
&& compilation.HasImplicitConversion(method.ReturnType, serviceType))
&& compilation.HasImplicitConversion(ProducedType(method.ReturnType, compilation), serviceType))
{
candidates.Add(method);
}
}

return candidates;
}

/// <summary>
/// The service type a factory's return type produces: the awaited result <c>T</c> for an asynchronous
/// factory returning <c>Task&lt;T&gt;</c> / <c>ValueTask&lt;T&gt;</c>, otherwise the return type
/// itself. A non-generic <c>Task</c> / <c>ValueTask</c> (no result) is not unwrapped, so it is matched
/// as-is and falls out as AWT108 (it produces no service).
/// </summary>
public static ITypeSymbol ProducedType(ITypeSymbol returnType, Compilation compilation)
=> IsAsyncFactoryReturn(returnType, compilation, out ITypeSymbol produced) ? produced : returnType;

/// <summary>
/// Whether <paramref name="returnType" /> is an awaitable factory return - <c>Task&lt;T&gt;</c> or
/// <c>ValueTask&lt;T&gt;</c> - yielding the produced result type <c>T</c>. Matched by the canonical
/// metadata symbols so a user-defined <c>Task`1</c> in another namespace is not mistaken for one.
/// <c>ValueTask&lt;T&gt;</c> is absent on netstandard2.0; <see cref="Compilation.GetTypeByMetadataName" />
/// returns <see langword="null" /> there and that branch is simply skipped.
/// </summary>
public static bool IsAsyncFactoryReturn(ITypeSymbol returnType, Compilation compilation, out ITypeSymbol produced)
{
if (returnType is INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1, } named)
{
INamedTypeSymbol definition = named.ConstructedFrom;
INamedTypeSymbol? task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1");
INamedTypeSymbol? valueTask = compilation.GetTypeByMetadataName("System.Threading.Tasks.ValueTask`1");
if (SymbolEqualityComparer.Default.Equals(definition, task)
|| (valueTask is not null && SymbolEqualityComparer.Default.Equals(definition, valueTask)))
{
produced = named.TypeArguments[0];
return true;
}
}

produced = returnType;
return false;
}
}
Loading
Loading