Skip to content
Closed
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
7 changes: 3 additions & 4 deletions Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@
AWT110 | Awaiten | Error | A registration sets both Factory and Instance
AWT111 | Awaiten | Error | An implementation is registered with conflicting production strategies
AWT112 | Awaiten | Error | A Factory registration names an overloaded method
AWT113 | Awaiten | Error | A Func<TArg...,T> relationship's runtime arguments do not match the service's [Arg] parameters
AWT113 | Awaiten | Error | A Func<TArg...,T> or Func<TArg...,Task<T>> relationship's runtime arguments do not match the service's [Arg] parameters
AWT114 | Awaiten | Error | A service with [Arg] parameters is registered with a non-Transient lifetime
AWT115 | Awaiten | Error | A service with [Arg] parameters is required as a plain or Lazy<T> dependency instead of a Func<TArg...,T>
AWT115 | Awaiten | Error | A service with [Arg] parameters is required as a plain, Lazy<T> or Task<T> dependency instead of a Func<TArg...,T>
AWT116 | Awaiten | Error | A [Container] class is not declared static
AWT117 | Awaiten | Error | Two registrations share the same service type and key
AWT118 | Awaiten | Warning | A root-owned instance holds a Func over a disposable build-on-demand service
AWT118 | Awaiten | Warning | A root-owned instance holds a Func or Func<…,Task<T>> over a disposable build-on-demand service
AWT119 | Awaiten | Error | A synchronous Func/Lazy/Owned relationship targets an async-initialized service
AWT120 | Awaiten | Error | A synchronous Func/Lazy/Owned relationship reaches an async-tainted service transitively
AWT121 | Awaiten | Error | A service with [Arg] parameters also implements IAsyncInitializable
26 changes: 20 additions & 6 deletions Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,24 +179,38 @@ private static void AddAccumulatingFuncs(
continue;
}

string service = AwaitenGenerator.Display(parameter.ServiceType);

// The leak-free remedy differs by relationship. A synchronous Func is redirected to a
// Func<…, Owned<T>> disposal handle; an async Func<…, Task<T>> cannot use Owned<T> (a synchronous
// handle that cannot await initialization - AWT119), so it is redirected to the async owned form
// Func<…, Task<Owned<T>>>, which async-resolves each instance into a throwaway scope.
string remedy = parameter.Kind == DependencyKind.FuncTask
? $"resolve it as Func<…, Task<Owned<{service}>>> for per-use disposal"
: $"resolve it as Func<…, Owned<{service}>> for per-use disposal";

diagnostics.Add(new DiagnosticInfo(
descriptor,
parameter.Location ?? graph.InstanceLocations[node],
new EquatableArray<string>([
AwaitenGenerator.Display(parameter.ServiceType),
service,
AwaitenGenerator.Display(holder.ImplementationType),
remedy,
]),
severity));
}
}

// A plain Func<…> (not a Func<…, Owned<T>>) over a build-on-demand service (a transient or parameterized
// service) whose construction tracks a fresh disposable on its owner - the produced service itself is
// disposable, or it transitively rebuilds a disposable transient. Each call to such a Func, bound to the
// root, builds and re-tracks those disposables on the root, so they accumulate for the container's lifetime.
// A plain Func<…> or its async form Func<…, Task<T>> (but not a Func<…, Owned<T>>) over a build-on-demand
// service (a transient or parameterized service) whose construction tracks a fresh disposable on its owner -
// the produced service itself is disposable, or it transitively rebuilds a disposable transient. Each call to
// such a Func, bound to the root, builds and re-tracks those disposables on the root, so they accumulate for
// the container's lifetime. The async resolver tracks disposables identically to the synchronous one, so the
// async factory leaks the same way and is included here - and since Owned<T> is unavailable for an async
// service, the async form is the only deferred factory that can reach an async-tainted target at all.
private static bool IsRootAccumulatingFunc(GraphModel graph, Dictionary<ServiceKey, int> serviceToIndex, ParameterModel parameter)
{
if (parameter.Kind != DependencyKind.Func || parameter.ProducesOwned
if (parameter.Kind is not (DependencyKind.Func or DependencyKind.FuncTask) || parameter.ProducesOwned
|| !graph.ServiceToImpl.TryGetValue(new ServiceKey(parameter.ServiceType, parameter.Key), out string? targetImpl)
|| !graph.ImplToIndex.TryGetValue(targetImpl, out int targetIndex))
{
Expand Down
120 changes: 89 additions & 31 deletions Source/Awaiten.SourceGenerators/AwaitenGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@
// InitializeAsync has warmed them, and the AWT119/AWT120 sync-resolution diagnostics are not reported.
bool syncResolveAfterInit = ReadSyncResolveAfterInit(containerSymbol);

// IAsyncDisposable support (the async drain, DisposeAsync on the Scope/Root, and tracking of
// async-disposable services) is emitted only when the consumer's compilation can see the type: net5.0+
// and netstandard2.1+ have it in-box, and an older target (e.g. net48 / netstandard2.0) may add it
// through Microsoft.Bcl.AsyncInterfaces. When it is absent the generated container is synchronous-dispose
// only and references no IAsyncDisposable, so it still compiles there.
bool hasAsyncDisposable = compilation.GetTypeByMetadataName("System.IAsyncDisposable") is not null;

List<DiagnosticInfo> diagnostics = new();

// The container must be a static class: it is a pure definition (registrations plus static factory
Expand Down Expand Up @@ -127,7 +134,8 @@
new EquatableArray<InstanceModel>(graph.Instances.ToArray()),
new EquatableArray<DiagnosticInfo>(diagnostics.ToArray()),
strict,
syncResolveAfterInit);
syncResolveAfterInit,
hasAsyncDisposable);
}

/// <summary>
Expand All @@ -144,6 +152,7 @@
CancellationToken cancellationToken)
{
INamedTypeSymbol? disposableSymbol = compilation.GetTypeByMetadataName("System.IDisposable");
INamedTypeSymbol? asyncDisposableSymbol = compilation.GetTypeByMetadataName("System.IAsyncDisposable");
INamedTypeSymbol? asyncInitializableSymbol = compilation.GetTypeByMetadataName("Awaiten.IAsyncInitializable");

List<RawRegistration> raw = ContainerRegistrations.Collect(containerSymbol);
Expand All @@ -162,7 +171,7 @@
foreach (ImplInfo info in implOrder)
{
cancellationToken.ThrowIfCancellationRequested();
InstanceModel? instance = BuildInstance(info, containerSymbol, compilation, serviceToImpl, disposableSymbol, asyncInitializableSymbol, diagnostics);
InstanceModel? instance = BuildInstance(info, containerSymbol, compilation, serviceToImpl, disposableSymbol, asyncDisposableSymbol, asyncInitializableSymbol, diagnostics);
if (instance is not null)
{
implToIndex[info.ImplementationType] = instances.Count;
Expand Down Expand Up @@ -216,7 +225,7 @@
}

InstanceModel instance = instances[node];
if (instance.IsDisposable)
if (instance.NeedsDisposal)
{
return true;
}
Expand Down Expand Up @@ -383,14 +392,15 @@
return dependencies;
}

private static InstanceModel? BuildInstance(
ImplInfo info,
INamedTypeSymbol containerSymbol,
Compilation compilation,
Dictionary<ServiceKey, string> serviceToImpl,
INamedTypeSymbol? disposableSymbol,
INamedTypeSymbol? asyncDisposableSymbol,
INamedTypeSymbol? asyncInitializableSymbol,
List<DiagnosticInfo> diagnostics)

Check warning on line 403 in Source/Awaiten.SourceGenerators/AwaitenGenerator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 8 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=Testably_Awaiten&issues=AZ8YX8rHVjWV1lPE_e9H&open=AZ8YX8rHVjWV1lPE_e9H&pullRequest=36
{
// A pre-built Instance is handed back from a container member, never constructed here. The
// container does not own it, so it is not disposed; the registered type may legitimately be an
Expand Down Expand Up @@ -446,15 +456,22 @@
: 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
// interface (or base class), which the static `disposable` flag above misses. When that is possible -
// the declared type is not itself disposable yet a subtype could be (an interface or a non-sealed
// class) - the emitter tracks the realized instance for disposal behind a runtime `is IDisposable`
// test instead. A sealed declared type that is not IDisposable cannot hide one, so it needs no check
// (and a runtime `is IDisposable` against it would not even compile). Constructed and pre-built
// Async disposal mirrors synchronous disposal: the container owns an IAsyncDisposable instance for
// teardown too, and the drain awaits its DisposeAsync (preferring it over IDisposable when a type is
// both). Only recognized when the compilation can see IAsyncDisposable (asyncDisposableSymbol non-null);

Check warning on line 461 in Source/Awaiten.SourceGenerators/AwaitenGenerator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=Testably_Awaiten&issues=AZ8YX8rHVjWV1lPE_e9G&open=AZ8YX8rHVjWV1lPE_e9G&pullRequest=36
// otherwise the generated container is synchronous-dispose only.
bool asyncDisposable = asyncDisposableSymbol is not null && ImplementsInterface(disposalType, asyncDisposableSymbol);

// A factory's declared return type can hide a concrete IDisposable (or IAsyncDisposable) behind a
// non-disposable service interface (or base class), which the static flags above miss. When that is
// possible - the declared type is itself neither yet a subtype could be (an interface or a non-sealed
// class) - the emitter tracks the realized instance for disposal behind a runtime
// `is IDisposable or IAsyncDisposable` test instead. A sealed declared type that is neither cannot hide
// one, so it needs no check (and the runtime test would not even compile). Constructed and pre-built
// Instance production never lie: info.Symbol is the concrete type, and an Instance is not owned.
bool runtimeDisposalCheck = info.Production == ProductionKind.Factory
&& !disposable
&& !asyncDisposable
&& CouldHideDisposable(disposalType);

// Async initialization follows the type the container actually owns - a factory's concrete return type
Expand Down Expand Up @@ -488,7 +505,8 @@
info.ProductionMember,
asyncInit,
IsAsyncFactory: asyncFactory,
RuntimeDisposalCheck: runtimeDisposalCheck);
RuntimeDisposalCheck: runtimeDisposalCheck,
IsAsyncDisposable: asyncDisposable);

static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface)
{
Expand Down Expand Up @@ -849,6 +867,18 @@
return new ParameterModel(ownedInner.ToDisplayString(FullyQualified), DependencyKind.Owned, Key: key, Location: location);
}

// A bare Task<T> dependency: an awaitable that resolves (and initializes) T. Task lives in
// System.Threading.Tasks, not System, so it is recognized here rather than through the System-generic
// relationship gate below (which handles the Func/Lazy wrappers, including Func<…, Task<T>>).
if (IsTask(parameter.Type, out ITypeSymbol taskResult))
{
// Task<Owned<T>> is the async counterpart of a bare Owned<T>: async-resolve (and initialize) T into a
// throwaway child scope and hand back the disposal handle.
return IsOwned(taskResult, out ITypeSymbol taskOwnedInner)
? new ParameterModel(taskOwnedInner.ToDisplayString(FullyQualified), DependencyKind.Task, Key: key, Location: location, ProducesOwned: true)
: new ParameterModel(taskResult.ToDisplayString(FullyQualified), DependencyKind.Task, Key: key, Location: location);
}

if (parameter.Type is INamedTypeSymbol { IsGenericType: true, } named
&& named.ContainingNamespace?.ToDisplayString() == "System"
&& ClassifyRelationship(named, key, location) is { } relationship)
Expand All @@ -872,8 +902,10 @@
{
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);
// Lazy<Task<T>> is the async counterpart of Lazy<T>: a memoized awaitable dependency.
return IsTask(named.TypeArguments[0], out ITypeSymbol lazyTaskResult)
? new ParameterModel(lazyTaskResult.ToDisplayString(FullyQualified), DependencyKind.LazyTask, Key: key, Location: location)
: new ParameterModel(named.TypeArguments[0].ToDisplayString(FullyQualified), DependencyKind.Lazy, Key: key, Location: location);
}

if (named is not { Name: "Func", TypeArguments.Length: >= 1, })
Expand All @@ -889,6 +921,21 @@
.Select(t => t.ToDisplayString(FullyQualified))
.ToArray();

// Func<…, Task<T>> is the async counterpart of Func<…, T>: an async factory that resolves (and
// initializes) T, awaiting it. It forwards any leading runtime arguments to T's [Arg] parameters.
// Func<…, Task<Owned<T>>> is its leak-free form: each call async-resolves T into a throwaway child scope
// and hands back the Owned<T> disposal handle (the async counterpart of Func<…, Owned<T>>).
if (IsTask(service, out ITypeSymbol funcTaskResult))
{
return IsOwned(funcTaskResult, out ITypeSymbol funcTaskOwnedInner)
? new ParameterModel(
funcTaskOwnedInner.ToDisplayString(FullyQualified), DependencyKind.FuncTask,
new EquatableArray<string>(argTypes), Key: key, Location: location, ProducesOwned: true)
: new ParameterModel(
funcTaskResult.ToDisplayString(FullyQualified), DependencyKind.FuncTask,
new EquatableArray<string>(argTypes), Key: key, Location: location);
}

// Func<…, Owned<T>> is the leak-free factory: its produced value is an Owned<T> disposal handle.
if (IsOwned(service, out ITypeSymbol funcOwnedInner))
{
Expand Down Expand Up @@ -1123,6 +1170,23 @@
return string.Join(" -> ", chain.Select(index => Display(instances[index].ImplementationType)));
}

// Whether a type is a System.Threading.Tasks.Task<T>, yielding its result type T. Used to recognize the
// async relationship types (Task<T>, Func<…, Task<T>>, Lazy<Task<T>>); ValueTask<T> is deliberately not a
// relationship type (a stored ValueTask may only be awaited once) - it is supported solely as an async
// factory's return type, on the producer side.
private static bool IsTask(ITypeSymbol type, out ITypeSymbol result)
{
if (type is INamedTypeSymbol { IsGenericType: true, Name: "Task", TypeArguments.Length: 1, } named
&& named.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks")
{
result = named.TypeArguments[0];
return true;
}

result = type;
return false;
}

// Whether a type is an Awaiten.Owned<T> disposal handle, yielding the owned service type T.
private static bool IsOwned(ITypeSymbol type, out ITypeSymbol inner)
{
Expand Down Expand Up @@ -1195,21 +1259,14 @@
new EquatableArray<string>([Display(instance.ImplementationType), instance.Lifetime.ToString(),])));
}

// A parameterized service is reachable only through a synchronous Func<TArg…, T>, which returns the
// 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,
location,
new EquatableArray<string>([Display(instance.ImplementationType),])));
}

// A parameterized async service (an [Arg] service that is IAsyncInitializable, is produced by an
// asynchronous Task<T> / ValueTask<T> factory, or transitively reaches one) is built fresh per call
// from its runtime arguments AND must await initialization, so its correct resolution path is the
// async parameterized factory relationship Func<TArg…, Task<T>>, which forwards the arguments to the
// async resolver. Misuse is caught at the consumption site rather than the registration: a synchronous
// Func<TArg…, T> over it is AWT119 (cannot await), and a plain / Lazy<T> / Task<T> dependency that
// supplies no arguments is AWT115 (parameterized requires a Func). There is therefore no
// registration-time diagnostic for the [Arg]-plus-async combination itself.
foreach (ParameterModel parameter in instance.ConstructorParameters.AsArray())
{
// Guard the implToIndex lookup the same way BuildDependencyGraph does: serviceToImpl can name an
Expand All @@ -1231,9 +1288,10 @@

/// <summary>
/// Validates a single (non-<c>[Arg]</c>) dependency against its target's runtime arguments
/// (<paramref name="expected" />): a <c>Func&lt;TArg…, T&gt;</c> must request exactly them (AWT113);
/// a plain or <c>Lazy&lt;T&gt;</c> dependency cannot supply them at all, so it must instead be a
/// <c>Func</c> when the target is parameterized (AWT115).
/// (<paramref name="expected" />): a <c>Func&lt;TArg…, T&gt;</c> or its async form
/// <c>Func&lt;TArg…, Task&lt;T&gt;&gt;</c> must request exactly them (AWT113); a plain, <c>Lazy&lt;T&gt;</c>
/// or <c>Task&lt;T&gt;</c> dependency cannot supply them at all, so a parameterized target must instead be
/// reached through a <c>Func</c> (AWT115).
/// </summary>
private static void ValidateDependency(
InstanceModel consumer,
Expand All @@ -1246,7 +1304,7 @@
// parameter has no usable location.
LocationInfo? location = parameter.Location ?? consumerLocation;

if (parameter.Kind == DependencyKind.Func)
if (parameter.Kind is DependencyKind.Func or DependencyKind.FuncTask)
{
string[] requested = parameter.FuncArgTypes.AsArray();
if (!requested.SequenceEqual(expected))
Expand Down
Loading
Loading