From 394e895fb99e50517f03b29b9a2babc18ce5a604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 06:54:22 +0200 Subject: [PATCH 1/6] feat: allow lifecycle hooks to take graph-resolved parameters OnActivated/OnRelease hooks may now declare parameters after the leading instance parameter; each is resolved from the container graph exactly like a constructor or factory parameter. Activation dependencies are passed inline at the call; release dependencies are resolved at construction and captured by value into the queued closure, so reverse creation-order teardown keeps them alive until the release has run. Hook parameters are full graph dependencies: AWT101 (missing), cycle, captive-dependency and async-taint analysis all account for them. A runtime [Arg] hook parameter is rejected with the new AWT189. --- Docs/pages/09-diagnostics.md | 15 +++ Docs/pages/lifetime/05-lifecycle-hooks.md | 22 ++++ .../AnalyzerReleases.Unshipped.md | 1 + .../AwaitenGenerator.Analysis.cs | 14 +++ .../AwaitenGenerator.Production.cs | 103 +++++++++++++---- .../Awaiten.SourceGenerators/Diagnostics.cs | 15 +++ .../Entities/InstanceModel.cs | 11 +- .../Sources/Sources.Disposal.cs | 109 +++++++++++++++--- .../Sources/Sources.Resolvers.cs | 36 +++--- ...gnosticTests.Awt164InvalidLifecycleHook.cs | 71 ++++++++++++ ...iagnosticTests.Awt189HookParameterIsArg.cs | 55 +++++++++ Tests/Awaiten.Tests/LifecycleHookTests.cs | 98 ++++++++++++++++ 12 files changed, 486 insertions(+), 64 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt189HookParameterIsArg.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 302f2bf8..8a5ed483 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -448,6 +448,21 @@ public static partial class CoffeeShop } ``` +### AWT189 + +:::danger[Error] +A lifecycle hook parameter (after the instance) is marked `[Arg]`, but a hook resolves its parameters from the graph. +::: + +```csharp +[Container] +[Singleton(OnActivated = nameof(Calibrate))] +public static partial class CoffeeShop +{ + private static void Calibrate(EspressoMachine machine, [Arg] int count) { } // no Func<…> call site supplies an [Arg] +} +``` + ## Runtime arguments ### AWT113 diff --git a/Docs/pages/lifetime/05-lifecycle-hooks.md b/Docs/pages/lifetime/05-lifecycle-hooks.md index a901864c..644bfb6d 100644 --- a/Docs/pages/lifetime/05-lifecycle-hooks.md +++ b/Docs/pages/lifetime/05-lifecycle-hooks.md @@ -20,6 +20,28 @@ You can type the parameter as `object` instead when one hook serves several impl `OnActivated` runs once, right after construction. For an async service it runs before `InitializeAsync`. `OnRelease` runs when the owner or scope is disposed, before the instance's own `Dispose`, and in reverse creation order. +## Hook parameters + +Both hooks may declare parameters after the instance. The first parameter is always the instance; every parameter after it is resolved from the container graph, exactly like a constructor or factory parameter. Calibrate the machine from injected settings; return a pooled buffer to the pool it came from. + +```csharp +[Container] +[Singleton] +[Singleton] +[Singleton(OnActivated = nameof(Calibrate))] +[Transient(Factory = nameof(Rent), OnRelease = nameof(ReturnToPool))] +public static partial class CoffeeShop +{ + private static void Calibrate(EspressoMachine machine, Settings settings) => machine.Calibrate(settings); + private static Buffer Rent(BufferPool pool) => pool.Rent(); + private static void ReturnToPool(Buffer buffer, BufferPool pool) => pool.Return(buffer); +} +``` + +An activation dependency is resolved inline as the hook is called. A release dependency is resolved at construction and captured by value into the queued closure, so the hook holds the instance it was queued for even if a later resolve fails and rolls back. Reverse creation-order teardown keeps a captured dependency (a singleton pool, say) alive until after the release that uses it has run. + +Hook parameters participate in the graph like any other dependency: an unregistered one is [AWT101](../diagnostics#awt101), and they are covered by cycle, captive-dependency and async-taint analysis. A hook parameter cannot be a runtime `[Arg]` (there is no `Func<…>` call site to supply one), which is [AWT189](../diagnostics#awt189). + ## Ordering and failure Activation happens before initialization and before the instance is ever handed out, so a concurrent resolve never sees a service that has not been activated. If `OnActivated` throws, the instance is not published. A later resolve builds a fresh one and tries again. Release is only queued once activation succeeds. diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index a5570050..035c3c2f 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -90,3 +90,4 @@ AWT186 | Awaiten | Error | An Owned relationship (or its Func/Task forms) targets a service produced by a requesting-type factory, which has no owner scope AWT187 | Awaiten | Warning | A [Scan(As = ScanAs.MatchingInterface)] matched a type implementing several same-named convention interfaces, so it registers under each AWT188 | Awaiten | Warning | A scan match's only exposure interface is inaccessible to the generated container, so the match is not registered + AWT189 | Awaiten | Error | A lifecycle hook parameter (after the instance) is marked [Arg], but a hook resolves its parameters from the graph diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs index c5160340..b37387b8 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs @@ -282,6 +282,20 @@ private static Dictionary> BuildEdges( AddParameterEdges(member.Dependency, serviceToImpl, implToIndex, serviceMembers, keyedMembers, includeEagerBare, nodeEdges); } + // A lifecycle hook's parameters (after the instance) are graph dependencies too: the activation hook + // resolves them during the owner's construction (an inline argument) and the release hook captures them + // then. Both are resolved eagerly like a Direct constructor parameter, so AddParameterEdges classifies + // them identically - contributing to cycle (AWT102), captive (AWT105) and async-taint analysis. + foreach (ParameterModel parameter in instances[i].ActivationParameters.AsArray()) + { + AddParameterEdges(parameter, serviceToImpl, implToIndex, serviceMembers, keyedMembers, includeEagerBare, nodeEdges); + } + + foreach (ParameterModel parameter in instances[i].ReleaseParameters.AsArray()) + { + AddParameterEdges(parameter, serviceToImpl, implToIndex, serviceMembers, keyedMembers, includeEagerBare, nodeEdges); + } + edges[i] = nodeEdges; } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index df8793e7..b4b154db 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -90,11 +90,12 @@ partial class AwaitenGenerator string realType = info.Symbol.ToDisplayString(FullyQualified); string? emitType = info.ImplementationType == realType ? null : realType; - // Lifecycle hooks (AWT164 when a named member is not a usable static void M(TImplementation)). Applied to + // Lifecycle hooks (AWT164 when a named member is not a usable static void M(TImplementation, …)). Applied to // constructed and factory-produced instances - the ones the container owns; a pre-built Instance returns - // above (the caller owns it, so activation/release do not apply). - string? onActivated = ResolveHook(containerSymbol, info, info.OnActivated, compilation, diagnostics); - string? onRelease = ResolveHook(containerSymbol, info, info.OnRelease, compilation, diagnostics); + // above (the caller owns it, so activation/release do not apply). A hook's parameters after the instance are + // graph dependencies (AWT101 when unregistered, AWT189 for a runtime [Arg]), resolved like a constructor's. + (string? onActivated, EquatableArray activationParameters) = ResolveHook(info, info.OnActivated, context); + (string? onRelease, EquatableArray releaseParameters) = ResolveHook(info, info.OnRelease, context); return new InstanceModel( info.ImplementationType, @@ -117,7 +118,9 @@ partial class AwaitenGenerator // coalesces onto the implementation, so this guard keeps a coalesced non-singleton from carrying it. Eager: info.Eager && info.Lifetime == Lifetime.Singleton, OnActivated: onActivated, - OnRelease: onRelease); + OnRelease: onRelease, + ActivationParameters: activationParameters, + ReleaseParameters: releaseParameters); static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface) { @@ -251,48 +254,98 @@ private static InstanceModel BuildPrebuiltInstance( } /// - /// Resolves an OnActivated / OnRelease lifecycle hook to a static void M(TImplementation) + /// Resolves an OnActivated / OnRelease lifecycle hook to a static void M(TImplementation, …) /// method on its owner - the container, or the module that declared the registration for an imported one - /// (never falling back to the container) - returning the name the generated Root/Scope calls it by, or - /// when the registration named none. The owner is a static class, so the hook is a - /// static method reached by simple name, exactly like a factory method - no receiver and no instance/static - /// distinction; a module hook is qualified with the module type (the generated container is another class, - /// so the simple name would not bind). The container's own members are reachable at any accessibility from - /// the generated partial, so a private hook qualifies, but a module's are not: a module method that - /// matches yet is inaccessible from the container is skipped (it cannot be called from the generated code), - /// falling through to AWT164. Reports - /// AWT164 and returns when no - /// accessible ordinary void method of that name accepts the implementation type. + /// (never falling back to the container) - returning the name the generated Root/Scope calls it by and its + /// graph-resolved parameters (every parameter after the instance), or (null, empty) when the + /// registration named none. The first parameter is the instance and accepts the implementation type; each + /// parameter after it is resolved from the object graph exactly like a constructor parameter (see + /// ). The owner is a static class, so the hook is a static method + /// reached by simple name, exactly like a factory method - no receiver and no instance/static distinction; + /// a module hook is qualified with the module type (the generated container is another class, so the simple + /// name would not bind). The container's own members are reachable at any accessibility from the generated + /// partial, so a private hook qualifies, but a module's are not: a module method that matches yet is + /// inaccessible from the container is skipped (it cannot be called from the generated code), falling through + /// to AWT164. Reports AWT164 and returns + /// (null, empty) when no accessible ordinary void method of that name accepts the implementation type + /// as its first parameter. /// - private static string? ResolveHook( - INamedTypeSymbol containerSymbol, + private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, string? hookName, - Compilation compilation, - List diagnostics) + BuildContext context) { if (hookName is null) { - return null; + return (null, default); } + INamedTypeSymbol containerSymbol = context.ContainerSymbol; + Compilation compilation = context.Compilation; + foreach (ISymbol member in AccessibleMembers(info.Origin ?? containerSymbol, hookName)) { // A module hook must also be accessible from the generated container (its own private members are // reachable from the partial, a module's are not); an inaccessible module method is not a usable hook. - if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary, IsStatic: true, ReturnsVoid: true, Parameters.Length: 1, } method + if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary, IsStatic: true, ReturnsVoid: true, Parameters.Length: >= 1, } method && compilation.HasImplicitConversion(info.Symbol, method.Parameters[0].Type) && (info.Origin is null || compilation.IsSymbolAccessibleWithin(method, containerSymbol))) { - return QualifiedHook(info, hookName); + return (QualifiedHook(info, hookName), ClassifyHookParameters(method, info, context)); } } - diagnostics.Add(new DiagnosticInfo( + context.Diagnostics.Add(new DiagnosticInfo( Diagnostics.InvalidLifecycleHook, info.Location, new EquatableArray([Display(info.OwningServiceOrImpl), hookName, DescribeOwner(info),]))); - return null; + return (null, default); + } + + /// + /// Classifies a lifecycle hook's parameters after the leading instance parameter into the graph + /// dependencies the container resolves and supplies to the hook, mirroring the constructor/factory pipeline + /// in (contextual binding, registered-collection suppression, variance and + /// the [ImportServices] fall-through), and reporting AWT101 + /// for an unregistered one. A parameter marked [Arg] is rejected with + /// AWT189 and dropped: runtime arguments flow only through a + /// Func<…> factory into [Arg] constructor parameters, and a hook has no such call site. + /// + private static EquatableArray ClassifyHookParameters(IMethodSymbol hook, ImplInfo info, BuildContext context) + { + List parameters = new(); + foreach (IParameterSymbol parameter in hook.Parameters.Skip(1)) + { + ParameterModel parameterModel = ClassifyParameter(parameter, asyncFactory: false, context.External.ServiceTypes); + + // AWT189: a hook parameter cannot be a runtime [Arg]. Dropped so it contributes no (unresolvable) graph + // edge and no emitted argument; the error already fails the build. + if (parameterModel.Kind == DependencyKind.Arg) + { + context.Diagnostics.Add(new DiagnosticInfo( + Diagnostics.HookParameterIsArg, + parameterModel.Location ?? info.Location, + new EquatableArray([parameter.Name, DisplayInstance(info.ImplementationType),]))); + continue; + } + + parameterModel = RedirectContextualBinding(parameterModel, info, context.ServiceToImpl, context.ConsumedConditionals); + parameterModel = SuppressRegisteredCollectionSynthesis(parameterModel, parameter.Type, context.ServiceToImpl); + parameterModel = RedirectVariance(parameterModel, parameter, context.ServiceToImpl, context.Variance); + RecordRequestedCollectionElement(parameterModel, parameter, context.Variance); + + if (context.External.ImportServices + && parameterModel is { Kind: DependencyKind.Direct, Key: null, } + && !context.ServiceToImpl.ContainsKey(KeyOf(parameterModel))) + { + parameterModel = parameterModel with { Kind = DependencyKind.External, }; + } + + parameters.Add(parameterModel); + ReportWhenUnregistered(parameterModel, info, context); + } + + return new EquatableArray(parameters.ToArray()); } /// diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 62811599..ad31dcfd 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1315,4 +1315,19 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + /// + /// A lifecycle hook parameter (after the leading instance parameter of an OnActivated / + /// OnRelease hook) is marked [Arg]. A hook's parameters after the instance are resolved from + /// the object graph, but a runtime argument flows only through a Func<…> factory into an + /// [Arg] constructor parameter, and a hook is invoked by the container with no such call site to + /// supply one. Drop the [Arg], or resolve the value from the graph instead. + /// + public static readonly DiagnosticDescriptor HookParameterIsArg = new( + "AWT189", + "Lifecycle hook parameter cannot be a runtime argument", + "the parameter '{0}' of the lifecycle hook for '{1}' is marked [Arg], but a hook's parameters are resolved from the graph; runtime arguments are supplied only through a Func<…> factory to constructor parameters, never to a lifecycle hook", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs index 2c72f4b9..3bfd9ac2 100644 --- a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs @@ -16,9 +16,12 @@ namespace Awaiten.SourceGenerators.Entities; /// IDisposable but could produce one at runtime, so the resolver tracks it behind a runtime type test. /// marks a singleton built at container build time rather than lazily on first resolve. /// / are the resolved names of the container's -/// static void M(TImplementation) lifecycle hooks. The activation hook runs once the instance is +/// static void M(TImplementation, …) lifecycle hooks. The activation hook runs once the instance is /// constructed; the release hook is queued at construction and run when the owning Root/Scope is disposed, -/// before the instance's own disposal. +/// before the instance's own disposal. Each hook's first parameter is the instance; any parameters after it +/// are graph dependencies, carried as / +/// and resolved exactly like a constructor parameter (an activation dependency inline at the call, a release +/// dependency captured by value into the queued closure). /// internal sealed record InstanceModel( string ImplementationType, @@ -39,7 +42,9 @@ internal sealed record InstanceModel( EquatableArray InjectedMembers = default, bool Eager = false, string? OnActivated = null, - string? OnRelease = null) + string? OnRelease = null, + EquatableArray ActivationParameters = default, + EquatableArray ReleaseParameters = default) { /// /// The concrete type to construct and to use for cache fields and resolver return types. Normally the same diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs index aa164e85..abcd4e57 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs @@ -1,5 +1,6 @@ using System.Text; using Awaiten.SourceGenerators.Entities; +using Awaiten.SourceGenerators.Internals; namespace Awaiten.SourceGenerators; @@ -52,20 +53,46 @@ private static bool HasReleaseHooks(InstanceModel[] instances) } /// - /// Emits the OnActivated hook call for a freshly built instance (OnActivated(variable);). The - /// hook is a static container method reached by simple name from the nested Root/Scope, - /// exactly like a factory method. Emitted only on the success path (a raced fresh resolve throws before - /// reaching it), so an instance that was torn down during a concurrent dispose is never activated. Nothing - /// when the instance names no activation hook. + /// Emits the OnActivated hook call for a freshly built instance (OnActivated(variable, dep…);). + /// The hook is a static container method reached by simple name from the nested Root/Scope, + /// exactly like a factory method. Any parameters after the instance are graph dependencies resolved inline + /// here, the same way a constructor argument is (awaited on the async path when async-tainted). Emitted only + /// on the success path (a raced fresh resolve throws before reaching it), so an instance that was torn down + /// during a concurrent dispose is never activated. Nothing when the instance names no activation hook. /// - private static void EmitActivation(StringBuilder builder, int depth, InstanceModel instance, string variable) + private static void EmitActivation(StringBuilder builder, int depth, InstanceModel instance, string variable, EmitContext context, bool asynchronous) { if (instance.OnActivated is not null) { - Indent(builder, depth).Append(instance.OnActivated).Append('(').Append(variable).AppendLine(");"); + Indent(builder, depth).Append(instance.OnActivated).Append('(').Append(variable) + .Append(HookArguments(instance.ActivationParameters, instance, context, asynchronous)).AppendLine(");"); } } + /// + /// The comma-prefixed argument list for a lifecycle hook's graph-resolved parameters (those after the + /// instance), each resolved through exactly like a constructor argument. + /// Empty when the hook takes only the instance. The instance presents its own typeof(…) as the + /// requesting type to any requesting-type factory a parameter consumes, mirroring construction. + /// + private static string HookArguments(EquatableArray parameters, InstanceModel instance, EmitContext context, bool asynchronous) + { + ParameterModel[] hookParameters = parameters.AsArray(); + if (hookParameters.Length == 0) + { + return string.Empty; + } + + string requestingType = RequestingTypeOf(instance); + StringBuilder arguments = new(); + foreach (ParameterModel parameter in hookParameters) + { + arguments.Append(", ").Append(DependencyValue(parameter, context.Instances, context.Names, context.ServiceToIndex, asynchronous, requestingType)); + } + + return arguments.ToString(); + } + /// /// Queues the OnRelease hook for a freshly built instance on the owner's __releases list /// ((__s.__releases ??= new List<Action>()).Add(() => OnRelease(variable))). The queue is @@ -77,13 +104,54 @@ private static void EmitActivation(StringBuilder builder, int depth, InstanceMod /// (singleton/scoped) path uses instead, which captures the /// published field into a local for the same by-value guarantee under wiring rollback. /// - private static void EmitReleaseRegistration(StringBuilder builder, int depth, InstanceModel instance, string variable) + private static void EmitReleaseRegistration(StringBuilder builder, int depth, InstanceModel instance, string variable, EmitContext context, bool asynchronous) { - if (instance.OnRelease is not null) + if (instance.OnRelease is null) { - Indent(builder, depth).Append("(__s.__releases ??= new global::System.Collections.Generic.List()).Add(() => ") - .Append(instance.OnRelease).Append('(').Append(variable).AppendLine("));"); + return; } + + string arguments = EmitReleaseCaptures(builder, depth, instance, context, asynchronous); + EmitReleaseAdd(builder, depth, instance, variable, arguments); + } + + /// + /// Resolves the release hook's graph-resolved parameters (those after the instance) into locals captured by + /// value (var __release0 = …;), so the queued closure holds the values resolved at construction time + /// rather than re-resolving them at teardown (when the owner may already be disposed). Mirrors the by-value + /// capture of the instance itself. Returns the comma-prefixed argument list referencing the locals; empty + /// (emitting nothing) when the hook takes only the instance. + /// + private static string EmitReleaseCaptures(StringBuilder builder, int depth, InstanceModel instance, EmitContext context, bool asynchronous) + { + ParameterModel[] parameters = instance.ReleaseParameters.AsArray(); + if (parameters.Length == 0) + { + return string.Empty; + } + + string requestingType = RequestingTypeOf(instance); + StringBuilder arguments = new(); + for (int p = 0; p < parameters.Length; p++) + { + string local = "__release" + p; + string value = DependencyValue(parameters[p], context.Instances, context.Names, context.ServiceToIndex, asynchronous, requestingType); + Indent(builder, depth).Append("var ").Append(local).Append(" = ").Append(value).AppendLine(";"); + arguments.Append(", ").Append(local); + } + + return arguments.ToString(); + } + + /// + /// Emits the __releases enqueue of the release closure + /// (Add(() => OnRelease(variable, dep…))) over the by-value and the + /// captured dependency (a comma-prefixed list, empty for an instance-only hook). + /// + private static void EmitReleaseAdd(StringBuilder builder, int depth, InstanceModel instance, string variable, string arguments) + { + Indent(builder, depth).Append("(__s.__releases ??= new global::System.Collections.Generic.List()).Add(() => ") + .Append(instance.OnRelease).Append('(').Append(variable).Append(arguments).AppendLine("));"); } /// @@ -97,14 +165,16 @@ private static void EmitReleaseRegistration(StringBuilder builder, int depth, In /// field only after activating, so it queues the release against that local via /// instead. Nothing when the instance names no hook. /// - private static void EmitCachedReleaseRegistration(StringBuilder builder, int depth, InstanceModel instance, string field, string type) + private static void EmitCachedReleaseRegistration(StringBuilder builder, int depth, InstanceModel instance, string field, string type, EmitContext context, bool asynchronous) { - if (instance.OnRelease is not null) + if (instance.OnRelease is null) { - Indent(builder, depth).Append(type).Append(" __released = __s.").Append(field).AppendLine(";"); - Indent(builder, depth).Append("(__s.__releases ??= new global::System.Collections.Generic.List()).Add(() => ") - .Append(instance.OnRelease).AppendLine("(__released));"); + return; } + + Indent(builder, depth).Append(type).Append(" __released = __s.").Append(field).AppendLine(";"); + string arguments = EmitReleaseCaptures(builder, depth, instance, context, asynchronous); + EmitReleaseAdd(builder, depth, instance, "__released", arguments); } /// @@ -238,18 +308,21 @@ private static void EmitFreshDisposalTracking(StringBuilder builder, int depth, /// activation, so that concurrent dispose tears it down; a non-disposable one simply is not released). /// Nothing when the instance names no hook. /// - private static void EmitFreshReleaseRegistration(StringBuilder builder, int depth, InstanceModel instance) + private static void EmitFreshReleaseRegistration(StringBuilder builder, int depth, InstanceModel instance, EmitContext context, bool asynchronous) { if (instance.OnRelease is null) { return; } + // Resolve the captured release dependencies before the lock: on the async path an async-tainted dependency + // is awaited, which cannot occur inside a lock. The instance itself is the by-value `created` local. + string arguments = EmitReleaseCaptures(builder, depth, instance, context, asynchronous); Indent(builder, depth).AppendLine("lock (__s.__gate)"); Indent(builder, depth).AppendLine("{"); Indent(builder, depth + 1).AppendLine("if (!__s.__disposed)"); Indent(builder, depth + 1).AppendLine("{"); - EmitReleaseRegistration(builder, depth + 2, instance, "created"); + EmitReleaseAdd(builder, depth + 2, instance, "created", arguments); Indent(builder, depth + 1).AppendLine("}"); Indent(builder, depth).AppendLine("}"); } diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Resolvers.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Resolvers.cs index 8753c2c6..0047e0ef 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Resolvers.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Resolvers.cs @@ -74,7 +74,7 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde string requestingConstruction = EmitConstruction(instance, context.Instances, names, context.ServiceToIndex); string requestingSignature = $"global::System.Type? {RequestingTypeParameterName}"; string requestingSummary = $"Resolves {XmlTypeRef(type)} through its requesting-type factory, passing the requesting consumer's typeof(…) (a new instance per call)."; - EmitFreshResolver(builder, depth, new FreshResolver("Scope", type, resolver, requestingSignature, requestingConstruction, DisposalOf(instance), requestingSummary), instance, asyncDisposal); + EmitFreshResolver(builder, depth, new FreshResolver("Scope", type, resolver, requestingSignature, requestingConstruction, DisposalOf(instance), requestingSummary), instance, asyncDisposal, context); return; } @@ -84,7 +84,7 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde string signature = string.Join(", ", argTypes.Select((t, i) => $"{t} a{i}")); string parameterizedConstruction = EmitConstruction(instance, context.Instances, names, context.ServiceToIndex); string parameterizedSummary = $"Resolves {XmlTypeRef(type)} from its [Arg] arguments (a new instance per call)."; - EmitFreshResolver(builder, depth, new FreshResolver("Scope", type, resolver, signature, parameterizedConstruction, DisposalOf(instance), parameterizedSummary), instance, asyncDisposal, DeferredEmitter(builder, instance, "created", context, asynchronous: false)); + EmitFreshResolver(builder, depth, new FreshResolver("Scope", type, resolver, signature, parameterizedConstruction, DisposalOf(instance), parameterizedSummary), instance, asyncDisposal, context, DeferredEmitter(builder, instance, "created", context, asynchronous: false)); return; } @@ -93,14 +93,14 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde if (instance.Lifetime == Lifetime.Transient) { string transientSummary = $"Resolves the transient {XmlTypeRef(type)} (a new instance per call)."; - EmitFreshResolver(builder, depth, new FreshResolver("Scope", type, resolver, string.Empty, construction, DisposalOf(instance), transientSummary), instance, asyncDisposal, DeferredEmitter(builder, instance, "created", context, asynchronous: false)); + EmitFreshResolver(builder, depth, new FreshResolver("Scope", type, resolver, string.Empty, construction, DisposalOf(instance), transientSummary), instance, asyncDisposal, context, DeferredEmitter(builder, instance, "created", context, asynchronous: false)); return; } string scopedSummary = $"Resolves the scoped {XmlTypeRef(type)} (one instance per scope)."; string scopedWiredFlag = HasDeferredMembers(instance) ? names.WiredField(index) : string.Empty; // The deferred members are wired onto the cached field on the owner (__s.), inside the static resolver. - EmitCachingResolver(builder, depth, new CachingResolver("Scope", type, resolver, (names.Field(index), scopedWiredFlag), construction, DisposalOf(instance), scopedSummary), instance, asyncDisposal, DeferredEmitter(builder, instance, "__s." + names.Field(index), context, asynchronous: false)); + EmitCachingResolver(builder, depth, new CachingResolver("Scope", type, resolver, (names.Field(index), scopedWiredFlag), construction, DisposalOf(instance), scopedSummary), instance, asyncDisposal, context, DeferredEmitter(builder, instance, "__s." + names.Field(index), context, asynchronous: false)); } /// @@ -120,7 +120,7 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde /// registered for teardown on the owner under the lock, re-checking __disposed so one built /// during a concurrent dispose is disposed here rather than leaked. /// - private static void EmitFreshResolver(StringBuilder builder, int depth, in FreshResolver resolver, InstanceModel instance, bool asyncDisposal, Action? emitDeferred = null) + private static void EmitFreshResolver(StringBuilder builder, int depth, in FreshResolver resolver, InstanceModel instance, bool asyncDisposal, EmitContext context, Action? emitDeferred = null) { string type = resolver.Type; string construction = resolver.Construction; @@ -177,12 +177,12 @@ private static void EmitFreshResolver(StringBuilder builder, int depth, in Fresh builder.AppendLine(); } - EmitActivation(builder, depth + 1, instance, "created"); + EmitActivation(builder, depth + 1, instance, "created", context, asynchronous: false); // Queue OnRelease only after OnActivated has run, so a failed activation queues no release; the // raced-safe block skips the queue when the owner was disposed since construction. if (instance.HasReleaseHook) { - EmitFreshReleaseRegistration(builder, depth + 1, instance); + EmitFreshReleaseRegistration(builder, depth + 1, instance, context, asynchronous: false); } Indent(builder, depth + 1).AppendLine("return created;"); @@ -223,7 +223,7 @@ private static void EmitRootResolver(StringBuilder builder, int depth, int index string singletonSummary = $"Resolves the singleton {XmlTypeRef(type)} (one instance per container)."; string singletonWiredFlag = HasDeferredMembers(instance) ? names.WiredField(index) : string.Empty; // The deferred members are wired onto the cached field on the owner (__s.), inside the static resolver. - EmitCachingResolver(builder, depth, new CachingResolver("Root", type, resolver, (names.Field(index), singletonWiredFlag), construction, DisposalOf(instance), singletonSummary), instance, context.AsyncDisposal, DeferredEmitter(builder, instance, "__s." + names.Field(index), context, asynchronous: false)); + EmitCachingResolver(builder, depth, new CachingResolver("Root", type, resolver, (names.Field(index), singletonWiredFlag), construction, DisposalOf(instance), singletonSummary), instance, context.AsyncDisposal, context, DeferredEmitter(builder, instance, "__s." + names.Field(index), context, asynchronous: false)); } /// @@ -611,9 +611,9 @@ private static void EmitGuardedWiringAndInit(StringBuilder builder, int depth, I if (DisposalOf(instance) == DisposalTracking.None) { emitDeferred?.Invoke(depth); - EmitActivation(builder, depth, instance, "created"); + EmitActivation(builder, depth, instance, "created", context, asynchronous: true); EmitAsyncInitialization(builder, depth, instance, "created"); - EmitFreshReleaseRegistration(builder, depth, instance); + EmitFreshReleaseRegistration(builder, depth, instance, context, asynchronous: true); return; } @@ -622,7 +622,7 @@ private static void EmitGuardedWiringAndInit(StringBuilder builder, int depth, I emitDeferred?.Invoke(depth + 1); // OnActivated runs post-construction, before initialization; inside the guarded try, so a throwing // activation tears the constructed instance down (like a throwing wiring/init) rather than leaking it. - EmitActivation(builder, depth + 1, instance, "created"); + EmitActivation(builder, depth + 1, instance, "created", context, asynchronous: true); EmitAsyncInitialization(builder, depth + 1, instance, "created"); Indent(builder, depth).AppendLine("}"); Indent(builder, depth).AppendLine("catch"); @@ -636,7 +636,7 @@ private static void EmitGuardedWiringAndInit(StringBuilder builder, int depth, I // Register disposal and queue the release only on success, so a failure that tore the instance down above // leaves neither behind. EmitAsyncDisposableRegistration(builder, depth, instance, context.AsyncDisposal); - EmitFreshReleaseRegistration(builder, depth, instance); + EmitFreshReleaseRegistration(builder, depth, instance, context, asynchronous: true); } /// @@ -822,7 +822,7 @@ private static int[] WarmUpTargets(InstanceModel[] instances, Lifetime lifetime) /// transitively through a peer), and a failed episode unpublishes what it built so a later resolve retries /// instead of silently returning a half-wired instance forever. /// - private static void EmitCachingResolver(StringBuilder builder, int depth, in CachingResolver resolver, InstanceModel instance, bool asyncDisposal, Action? emitDeferred = null) + private static void EmitCachingResolver(StringBuilder builder, int depth, in CachingResolver resolver, InstanceModel instance, bool asyncDisposal, EmitContext context, Action? emitDeferred = null) { string type = resolver.Type; string field = resolver.Field; @@ -875,8 +875,8 @@ private static void EmitCachingResolver(StringBuilder builder, int depth, in Cac // successfully-activated instance is released), capturing the local by value. Indent(builder, depth + 3).Append(type).Append(" created = ").Append(construction).AppendLine(";"); EmitCachedDisposalRegistration(builder, depth + 3, "created", disposal, asyncDisposal); - EmitActivation(builder, depth + 3, instance, "created"); - EmitReleaseRegistration(builder, depth + 3, instance, "created"); + EmitActivation(builder, depth + 3, instance, "created", context, asynchronous: false); + EmitReleaseRegistration(builder, depth + 3, instance, "created", context, asynchronous: false); Indent(builder, depth + 3).Append("__s.").Append(field).AppendLine(" = created;"); } else @@ -886,7 +886,7 @@ private static void EmitCachingResolver(StringBuilder builder, int depth, in Cac // cache-miss (the owner is known not disposed here, so no separate raced check is needed). Indent(builder, depth + 3).Append("__s.").Append(field).Append(" = ").Append(construction).AppendLine(";"); EmitCachedDisposalRegistration(builder, depth + 3, "__s." + field, disposal, asyncDisposal); - EmitCachedReleaseRegistration(builder, depth + 3, instance, field, type); + EmitCachedReleaseRegistration(builder, depth + 3, instance, field, type, context, asynchronous: false); } } else @@ -906,8 +906,8 @@ private static void EmitCachingResolver(StringBuilder builder, int depth, in Cac // episode back (unpublishing the field) rather than leaving a published, half-activated instance, and // the release is queued only after activation succeeds. The release captures the instance by value, so // a rollback triggered by a later peer in the same episode cannot orphan it onto the nulled field. - EmitActivation(builder, depth + 4, instance, "__s." + field); - EmitCachedReleaseRegistration(builder, depth + 4, instance, field, type); + EmitActivation(builder, depth + 4, instance, "__s." + field, context, asynchronous: false); + EmitCachedReleaseRegistration(builder, depth + 4, instance, field, type, context, asynchronous: false); Indent(builder, depth + 3).AppendLine("}"); Indent(builder, depth + 3).AppendLine("catch"); Indent(builder, depth + 3).AppendLine("{"); diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs index 0aafe42c..89903d50 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs @@ -71,6 +71,77 @@ await That(result.Diagnostics.Any(d => d.Contains("AWT164"))).IsTrue() .Because("a lifecycle hook must be a void method"); } + [Fact] + public async Task ReportsWhenTheFirstParameterDoesNotAcceptTheImplementation_EvenWithALaterParameterThatWould() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + public sealed class Settings { } + + [Container] + [Singleton] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Settings settings, Service service) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT164"))).IsTrue() + .Because("the hook's first parameter is the instance, so it must accept the implementation type"); + } + + [Fact] + public async Task DoesNotReportForAHookTakingTheInstancePlusGraphParameters() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + public sealed class Settings { } + + [Container] + [Singleton] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, Settings settings) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT164") || d.Contains("AWT101"))).IsFalse() + .Because("the instance parameter is first and the extra Settings parameter is registered on the graph"); + } + + [Fact] + public async Task ReportsAwt101ForAnUnregisteredHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + public sealed class Missing { } + + [Container] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, Missing missing) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT101"))).IsTrue() + .Because("a lifecycle hook parameter after the instance is a graph dependency that must be registered"); + } + [Fact] public async Task DoesNotReportForAVoidMethodAcceptingTheImplementation() { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt189HookParameterIsArg.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt189HookParameterIsArg.cs new file mode 100644 index 00000000..ac9ce73b --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt189HookParameterIsArg.cs @@ -0,0 +1,55 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt189HookParameterIsArg + { + [Fact] + public async Task ReportsWhenAHookParameterIsMarkedArg() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + + [Container] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, [Arg] int count) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT189"))).IsTrue() + .Because("a runtime [Arg] cannot be supplied to a lifecycle hook - its parameters resolve from the graph"); + } + + [Fact] + public async Task DoesNotReportForAGraphResolvedHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + public sealed class Settings { } + + [Container] + [Singleton] + [Transient(OnRelease = nameof(Released))] + public static partial class MyContainer + { + private static void Released(Service service, Settings settings) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT189"))).IsFalse() + .Because("a hook parameter resolved from the graph is not a runtime argument"); + } + } +} diff --git a/Tests/Awaiten.Tests/LifecycleHookTests.cs b/Tests/Awaiten.Tests/LifecycleHookTests.cs index 4091f595..c1c9deba 100644 --- a/Tests/Awaiten.Tests/LifecycleHookTests.cs +++ b/Tests/Awaiten.Tests/LifecycleHookTests.cs @@ -226,6 +226,56 @@ await That(sawActivated.All(seen => seen)).IsTrue() await That(Probe.Log.Count(entry => entry == "activated:Slow")).IsEqualTo(1); } + [Fact] + public async Task OnActivated_ReceivesAGraphDependency() + { + Probe.Reset(); + using ActivationDependencyContainer.Root container = new(); + + Settings settings = container.Resolve(); + EspressoMachine machine = container.Resolve(); + + // The activation hook took a second, graph-resolved parameter (the singleton Settings) alongside the + // instance, and applied it - proving hook parameters after the instance resolve from the object graph. + await That(machine.AppliedSettings).IsSameAs(settings) + .Because("the activation hook's extra parameter resolves from the container graph"); + } + + [Fact] + public async Task OnRelease_ReceivesAGraphDependency_ReturningToAPool() + { + Probe.Reset(); + Pool pool; + PooledBuffer buffer; + using (PoolContainer.Root container = new()) + { + buffer = container.Resolve(); + pool = container.Resolve(); + + // Nothing has been released while the container is alive. + await That(pool.Returned).DoesNotContain(buffer); + } + + // On disposal the release hook ran with its graph-resolved Pool parameter and returned the buffer to it. + await That(pool.Returned).Contains(buffer) + .Because("the release hook's extra parameter resolves from the container graph"); + } + + [Fact] + public async Task ReleaseHookDependency_OutlivesTheRelease_InReverseCreationOrder() + { + Probe.Reset(); + using (PoolContainer.Root container = new()) + { + // PooledBuffer takes the Pool in its constructor, so the Pool is created first and, drained in reverse + // creation order, is released last - it is still alive when the buffer's release hook uses it. + container.Resolve(); + } + + await That(Probe.Log.IndexOf("released:PooledBuffer") < Probe.Log.IndexOf("released:Pool")).IsTrue() + .Because("a release hook's captured dependency must still be alive when the release runs"); + } + public sealed class Alpha; public sealed class Beta; @@ -406,4 +456,52 @@ private static void Activate(Slow slow) Probe.Log.Add("activated:Slow"); } } + + public sealed class Settings; + + public sealed class EspressoMachine + { + public Settings? AppliedSettings { get; private set; } + + public void Calibrate(Settings settings) => AppliedSettings = settings; + } + + public sealed class Pool + { + public readonly List Returned = new(); + + public void Return(object item) => Returned.Add(item); + } + + public sealed class PooledBuffer + { + // Takes the Pool in its constructor so the Pool is created first; reverse-order release then keeps the Pool + // alive until after the buffer's release hook has used it. + public PooledBuffer(Pool pool) => _ = pool; + } + + [Container] + [Singleton] + [Singleton(OnActivated = nameof(Calibrate))] + public static partial class ActivationDependencyContainer + { + // The hook takes the instance plus a graph-resolved Settings dependency after it. + private static void Calibrate(EspressoMachine machine, Settings settings) => machine.Calibrate(settings); + } + + [Container] + [Singleton(OnRelease = nameof(ReleasePool))] + [Transient(OnRelease = nameof(ReturnToPool))] + public static partial class PoolContainer + { + // The release hook takes the instance plus a graph-resolved Pool dependency, captured by value at + // construction and used to return the buffer when the container is disposed. + private static void ReturnToPool(PooledBuffer buffer, Pool pool) + { + pool.Return(buffer); + Probe.Log.Add("released:PooledBuffer"); + } + + private static void ReleasePool(Pool pool) => Probe.Log.Add("released:Pool"); + } } From 434cd885380c266104ee979daaaa87f8d39359f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 11:19:58 +0200 Subject: [PATCH 2/6] fix: route lifecycle hook parameters through every dependency-walking pass The graph-resolved parameters of OnActivated/OnRelease hooks were classified as full graph dependencies but only registered as edges in BuildEdges, so every other pass that walks an instance's constructor parameters skipped them. That let a hook parameter escape validation and the emitted infrastructure it needs, producing generated code that does not compile with no Awaiten diagnostic to explain it: an [ImportService] hook parameter emitted a __ResolveExternal call the container never declared (CS1061); a plain hook parameter over a parameterized target missed AWT115 (CS7036); an Owned hook parameter over a requesting-type factory missed AWT186 (CS1503); a synchronous Func hook parameter over an async-tainted target missed AWT119/120 (CS0103); and a collection hook parameter with an async-initialized member missed AWT122 (CS0117). An [ImportService] consumed only by a hook parameter was also wrongly reported unconsumed (AWT176). Add InstanceModel.HookParameters() (activation plus release parameters) and visit it wherever ConstructorParameters is walked: external-dependency advertisement and the __ResolveExternal/__AsyncArray emit gates, AWT115/118/119/120/122/186 analysis, the unconsumed-import check, the fresh-disposable transient walk, and the captive-dependency message naming. BuildEdges now uses the same helper instead of iterating the two arrays separately. Cover each failing scenario with a hook-parameter test in its owning diagnostic file, and add runtime tests proving an external hook parameter both compiles and resolves through the external resolver and is advertised as an external dependency. --- .../AwaitenAnalyzer.cs | 5 +- .../AwaitenGenerator.Analysis.cs | 46 +++++++++++++------ .../AwaitenGenerator.Coalescing.cs | 7 +++ .../Entities/InstanceModel.cs | 11 +++++ .../Sources/Sources.Construction.cs | 7 +-- .../Sources/Sources.Dispatch.cs | 10 ++++ ...icTests.Awt115ParameterizedRequiresFunc.cs | 27 +++++++++++ ...sticTests.Awt118RootAccumulatingFactory.cs | 25 ++++++++++ ....Awt119Awt120SynchronousAsyncResolution.cs | 31 +++++++++++++ .../DiagnosticTests.Awt122AsyncCollection.cs | 31 +++++++++++++ ...icTests.Awt176UnconsumedExternalService.cs | 24 ++++++++++ .../GeneralTests.cs | 27 +++++++++++ Tests/Awaiten.Tests/ExternalServiceTests.cs | 36 +++++++++++++++ 13 files changed, 270 insertions(+), 17 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs index 57626c91..e089845a 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs @@ -225,8 +225,11 @@ private static void AddAccumulatingFuncs( DiagnosticDescriptor descriptor = strict ? Diagnostics.RootAccumulatingFactoryStrict : Diagnostics.RootAccumulatingFactory; DiagnosticSeverity? severity = strict ? DiagnosticSeverity.Error : null; + // A lifecycle hook's Func parameter is rooted too - a release hook captures it by value into the owner's + // teardown closure - so an accumulating fresh-disposable Func reached through one is reported like a + // constructor parameter's. InstanceModel holder = graph.Instances[node]; - foreach (ParameterModel parameter in holder.ConstructorParameters.AsArray()) + foreach (ParameterModel parameter in holder.ConstructorParameters.AsArray().Concat(holder.HookParameters())) { if (!IsRootAccumulatingFunc(graph, serviceToIndex, membership, parameter) || !reported.Add($"{node}|{parameter.ServiceType}|{parameter.Key}")) { diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs index b37387b8..8321c8df 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs @@ -111,7 +111,10 @@ private static void PushFreshTransientDependencies( CollectionMembership membership, Stack stack) { - foreach (ParameterModel parameter in instance.ConstructorParameters.AsArray()) + // A lifecycle hook's graph-resolved parameters are built as part of constructing the instance too (an + // activation dependency inline, a release dependency captured), so a transient disposable reached through + // one is a fresh disposable of this build like a constructor parameter's. + foreach (ParameterModel parameter in instance.ConstructorParameters.AsArray().Concat(instance.HookParameters())) { if (parameter.Kind is DependencyKind.Enumerable or DependencyKind.AsyncEnumerable or DependencyKind.AwaitedEnumerable) { @@ -286,12 +289,7 @@ private static Dictionary> BuildEdges( // resolves them during the owner's construction (an inline argument) and the release hook captures them // then. Both are resolved eagerly like a Direct constructor parameter, so AddParameterEdges classifies // them identically - contributing to cycle (AWT102), captive (AWT105) and async-taint analysis. - foreach (ParameterModel parameter in instances[i].ActivationParameters.AsArray()) - { - AddParameterEdges(parameter, serviceToImpl, implToIndex, serviceMembers, keyedMembers, includeEagerBare, nodeEdges); - } - - foreach (ParameterModel parameter in instances[i].ReleaseParameters.AsArray()) + foreach (ParameterModel parameter in instances[i].HookParameters()) { AddParameterEdges(parameter, serviceToImpl, implToIndex, serviceMembers, keyedMembers, includeEagerBare, nodeEdges); } @@ -460,6 +458,13 @@ private static void DetectSynchronousAsyncResolution( { CheckDependency(i, member.Dependency); } + + // A hook's synchronous relationship parameter resolves its target without awaiting initialization, + // exactly like a constructor parameter's, so it is checked the same way. + foreach (ParameterModel parameter in instances[i].HookParameters()) + { + CheckDependency(i, parameter); + } } void CheckDependency(int consumer, ParameterModel parameter) @@ -532,6 +537,13 @@ private static void DetectOwnedOverRequestingTypeFactory( { CheckDependency(i, member.Dependency); } + + // A hook's Owned-family parameter has the same structural incompatibility with a requesting-type + // factory as a constructor parameter's (the emit path has no owned form for it), so it is checked too. + foreach (ParameterModel parameter in instances[i].HookParameters()) + { + CheckDependency(i, parameter); + } } void CheckDependency(int consumer, ParameterModel parameter) @@ -602,6 +614,12 @@ private static void DetectSynchronousAsyncCollection( { CheckCollection(i, dependency); } + + // A hook's collection parameter is materialized through the same synchronous expression too. + foreach (ParameterModel parameter in instances[i].HookParameters()) + { + CheckCollection(i, parameter); + } } void CheckCollection(int consumer, ParameterModel dependency) @@ -724,8 +742,9 @@ private static void ValidateRuntimeArguments( // A parameterized async service is built fresh per call AND must await initialization, so its path is // Func>. Misuse is caught at the consumption site (AWT119 or AWT115), not the - // registration, so there is no registration-time diagnostic for [Arg]-plus-async. - foreach (ParameterModel parameter in instance.ConstructorParameters.AsArray()) + // registration, so there is no registration-time diagnostic for [Arg]-plus-async. A hook parameter is a + // graph dependency too, so a plain hook parameter over a parameterized target is AWT115 like a constructor's. + foreach (ParameterModel parameter in instance.ConstructorParameters.AsArray().Concat(instance.HookParameters())) { // A collection resolves to a set of members, not a single registration whose [Arg] parameters could // be supplied, so runtime-argument matching does not apply (excluded explicitly). Guard the @@ -855,13 +874,14 @@ private static void ReportCapturedScoped( } } - // The service key the parent's constructor used to reach this dependency (the alias the developer wrote, - // including any [FromKey]). Falls back to the first service key, or the implementation type when the - // dependency exposes no service of its own, so the diagnostic still names it. + // The service key the parent used to reach this dependency (the alias the developer wrote, including any + // [FromKey]) - across its constructor parameters and lifecycle hook parameters. Falls back to the first + // service key, or the implementation type when the dependency exposes no service of its own, so the + // diagnostic still names it. static ServiceKey ReferencedService(InstanceModel parent, InstanceModel dependency) { ServiceKey[] dependencyServices = dependency.Services.AsArray(); - foreach (ParameterModel parameter in parent.ConstructorParameters.AsArray()) + foreach (ParameterModel parameter in parent.ConstructorParameters.AsArray().Concat(parent.HookParameters())) { ServiceKey key = KeyOf(parameter); if (dependencyServices.Contains(key)) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs index 767594d0..5b08395c 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs @@ -315,6 +315,13 @@ private static void ReportUnconsumedExternalServices( { RecordExternalConsumption(member.Dependency, consumed); } + + // A lifecycle hook parameter consumes an external dependency too, so an import used only by a hook is + // not unconsumed (AWT176 would otherwise wrongly advise removing it). + foreach (ParameterModel parameter in instance.HookParameters()) + { + RecordExternalConsumption(parameter, consumed); + } } LocationInfo? location = LocationInfo.From(containerSymbol.Locations.FirstOrDefault()); diff --git a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs index 3bfd9ac2..c3df7142 100644 --- a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs @@ -96,4 +96,15 @@ public string[] ArgTypes() => ConstructorParameters.AsArray() /// Whether this instance runs a lifecycle hook when the owning Root/Scope is disposed. public bool HasReleaseHook => OnRelease is not null; + + /// + /// The lifecycle hooks' graph-resolved parameters (those after the leading instance parameter of the + /// OnActivated and OnRelease hooks), classified exactly like constructor parameters and + /// resolved eagerly at construction (the activation dependency inline at the call, the release dependency + /// captured by value). Every dependency-walking pass that visits must + /// also visit these, or a hook dependency escapes cycle/captive/async/argument analysis and the emitted + /// infrastructure it needs. + /// + public ParameterModel[] HookParameters() + => ActivationParameters.AsArray().Concat(ReleaseParameters.AsArray()).ToArray(); } diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Construction.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Construction.cs index fd77451c..13300854 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Construction.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Construction.cs @@ -19,11 +19,12 @@ internal static partial class Sources /// /// Whether the __AsyncArray<T> helper is used: some instance injects an - /// IAsyncEnumerable<T>, or some unkeyed, non-suppressed collection is offered by type as - /// IAsyncEnumerable<T> (skipping a registered async shape). + /// IAsyncEnumerable<T> through a constructor or lifecycle hook parameter, or some unkeyed, + /// non-suppressed collection is offered by type as IAsyncEnumerable<T> (skipping a registered + /// async shape). /// private static bool NeedsAsyncArrayHelper(InstanceModel[] instances, Names names, Dictionary serviceToIndex) - => instances.Any(instance => instance.ConstructorParameters.AsArray().Any(p => p.Kind == DependencyKind.AsyncEnumerable)) + => instances.Any(instance => instance.ConstructorParameters.AsArray().Concat(instance.HookParameters()).Any(p => p.Kind == DependencyKind.AsyncEnumerable)) || names.Collections.Any(collection => collection.Key is null && !SynthesisSuppressed(serviceToIndex, collection.Service) && !AsyncShapeRegistered(serviceToIndex, collection.Service)); diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs index ae7e2d32..c6863a33 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs @@ -1112,6 +1112,16 @@ private static (string Type, string? Key)[] ExternalDependencies(InstanceModel[] external.Add((dependency.ServiceType, dependency.Key)); } } + + // A lifecycle hook parameter can be an external dependency too, and it is emitted through the same + // __ResolveExternal surface, so it must advertise the type and drive that surface's emission. + foreach (ParameterModel parameter in instance.HookParameters()) + { + if (parameter.Kind == DependencyKind.External && seen.Add((parameter.ServiceType, parameter.Key))) + { + external.Add((parameter.ServiceType, parameter.Key)); + } + } } return external.ToArray(); diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt115ParameterizedRequiresFunc.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt115ParameterizedRequiresFunc.cs index 4cd88571..297d0968 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt115ParameterizedRequiresFunc.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt115ParameterizedRequiresFunc.cs @@ -116,5 +116,32 @@ public static partial class MyContainer await That(awt115).Contains($"({dependencyLine},") .Because("the diagnostic points at the plain Robot dependency, not the [Singleton] registration"); } + + [Fact] + public async Task ReportsForALifecycleHookParameterOverAParameterizedTarget() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Robot + { + public Robot([Arg] string name) { } + } + public sealed class Plant { } + + [Container] + [Transient] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Plant plant, Robot robot) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT115*").AsWildcard() + .Because("a plain hook parameter cannot supply a parameterized target's runtime arguments, exactly like a constructor parameter"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs index 90e43ddc..c0b6760f 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs @@ -298,5 +298,30 @@ public static partial class MyContainer await That(diagnostics).Contains("*AWT118*").AsWildcard() .Because("building the non-disposable Consumer on demand materializes its awaited keyed dictionary of disposable transients (the task starts materializing them at construction), which accumulate on the root - the transitive-disposable walk follows awaited-keyed-dictionary edges too"); } + + [Fact] + public async Task ReportsWhenASingletonReleaseHookHoldsAFuncOverADisposableTransient() + { + string[] diagnostics = await Analyzer.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public sealed class Tool : IDisposable { public void Dispose() { } } + public sealed class Depot { } + + [Container] + [Transient] + [Singleton(OnRelease = nameof(Drain))] + public static partial class MyContainer + { + private static void Drain(Depot depot, Func tools) { } + } + """); + + await That(diagnostics.Any(d => d.Contains("AWT118"))).IsTrue() + .Because("a singleton release hook captures its Func over a disposable transient into the root's teardown closure, so its instances accumulate on the root like a constructor-held Func"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt119Awt120SynchronousAsyncResolution.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt119Awt120SynchronousAsyncResolution.cs index a5cae7c8..9d5f663b 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt119Awt120SynchronousAsyncResolution.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt119Awt120SynchronousAsyncResolution.cs @@ -254,5 +254,36 @@ await That(result.Diagnostics).DoesNotContain("*AWT119*").AsWildcard() .Because("pragmatic mode allows synchronous resolution of async-initialized services after warm-up"); await That(result.Diagnostics).DoesNotContain("*AWT120*").AsWildcard(); } + + [Fact] + public async Task ReportsForASynchronousRelationshipLifecycleHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Connection : IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + public sealed class Service { } + + [Container] + [Singleton] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, Func connection) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT119*").AsWildcard() + .Because("a synchronous Func hook parameter resolves an async-initialized target without awaiting it, like a constructor parameter"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt122AsyncCollection.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt122AsyncCollection.cs index 9d1148a3..8968e61a 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt122AsyncCollection.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt122AsyncCollection.cs @@ -180,5 +180,36 @@ public static partial class MyContainer await That(result.Diagnostics).DoesNotContain("*AWT122*").AsWildcard() .Because("the awaited keyed dictionary awaits its async-initialized members behind the produced task, exactly as the awaited collection does"); } + + [Fact] + public async Task ReportsWhenALifecycleHookCollectionParameterHasAnAsyncMember() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AsyncPlugin : IPlugin, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + public sealed class Host { } + + [Container] + [Singleton] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Host host, IEnumerable plugins) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT122*").AsWildcard() + .Because("a hook's synchronously materialized collection parameter cannot await an async-initialized member, like a constructor parameter"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt176UnconsumedExternalService.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt176UnconsumedExternalService.cs index b86a95ae..b1991706 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt176UnconsumedExternalService.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt176UnconsumedExternalService.cs @@ -109,5 +109,29 @@ public static partial class MyContainer await That(result.Diagnostics.Any(d => d.Contains("AWT176"))).IsFalse() .Because("a consumed [ImportService] declaration is live"); } + + [Fact] + public async Task DoesNotReportWhenTheExternalTypeIsConsumedByALifecycleHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface ILogger { } + public sealed class Service { } + + [Container] + [ImportService] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, ILogger logger) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT176"))).IsFalse() + .Because("an [ImportService] consumed only by a lifecycle hook parameter is still live"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs b/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs index 843fc72f..e3741340 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs @@ -2263,6 +2263,33 @@ await That(result.Diagnostics.Any(d => d.Contains("AWT186"))).IsTrue() .Because("the detection pass walks injected [Inject] members like constructor parameters"); } + [Fact] + public async Task RequestingType_WithAnOwnedLifecycleHookParameter_ReportsAwt186() + { + GeneratorResult result = Generator.Run(""" + using System; + using Awaiten; + + namespace MyCode; + + public interface ILogger { } + public sealed class Logger : ILogger, IDisposable { public Logger(string c) { } public void Dispose() { } } + public sealed class Alpha { } + + [Container] + [Transient(Factory = nameof(CreateLogger))] + [Transient(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static ILogger CreateLogger([RequestingType] Type? t) => new Logger(t?.FullName ?? ""); + private static void Started(Alpha alpha, Owned logger) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT186"))).IsTrue() + .Because("the detection pass walks lifecycle hook parameters like constructor parameters"); + } + [Fact] public async Task RequestingType_WithFuncAndLazyRelationships_DoesNotReportAwt186() { diff --git a/Tests/Awaiten.Tests/ExternalServiceTests.cs b/Tests/Awaiten.Tests/ExternalServiceTests.cs index 996c81d5..8e9d99e2 100644 --- a/Tests/Awaiten.Tests/ExternalServiceTests.cs +++ b/Tests/Awaiten.Tests/ExternalServiceTests.cs @@ -45,6 +45,12 @@ public sealed class KeyedReporter public IClock Clock { get; } } + public sealed class HookReporter + { + // No Awaiten registration for IClock: the activation hook's IClock parameter routes externally too. + public IClock? Clock { get; set; } + } + [Container] [ImportService] [Transient] @@ -52,6 +58,14 @@ public sealed class KeyedReporter [Transient] public static partial class ExternalContainer; + [Container] + [ImportService] + [Singleton(OnActivated = nameof(ApplyClock))] + public static partial class HookExternalContainer + { + private static void ApplyClock(HookReporter reporter, IClock clock) => reporter.Clock = clock; + } + private sealed class ClockResolver : IExternalResolver { private readonly Dictionary _keyed = new() @@ -121,4 +135,26 @@ await That(((IAwaitenContainerMetadata)container).ExternalDependencies) .And.Contains(new AwaitenExternalDependency(typeof(IClock), "utc")) .Because("the keyed consumer advertises the forwarded key"); } + + [Fact] + public async Task ImportService_LifecycleHookParameter_ResolvesThroughTheExternalResolver() + { + using HookExternalContainer.Root container = new(); + ((IExternalResolverHost)container).ExternalResolver = new ClockResolver(); + + HookReporter reporter = container.Resolve(); + + await That(reporter.Clock).Is() + .Because("a lifecycle hook parameter of an [ImportService] type is drawn from the external resolver"); + } + + [Fact] + public async Task ImportService_ConsumedOnlyByAHookParameter_AdvertisesTheExternalDependency() + { + using HookExternalContainer.Root container = new(); + + await That(((IAwaitenContainerMetadata)container).ExternalDependencies) + .Contains(new AwaitenExternalDependency(typeof(IClock))) + .Because("an [ImportService] reached only through a lifecycle hook parameter is still advertised and driven onto the external surface"); + } } From de036eaf9a1fdf6c54f7ead80625ea61730651fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 12:20:12 +0200 Subject: [PATCH 3/6] feat: report an ambiguous lifecycle hook (AWT190) and tighten the release-order test A lifecycle hook is reached by simple name, so when a registration names an overloaded method whose overloads all accept the instance as their first parameter, the container's choice - and the graph dependencies the extra parameters resolve - was silently order-dependent. Collect every accessible static void method of that name accepting the instance and, when more than one qualifies, report AWT190 and emit no hook, mirroring how AWT112 handles an ambiguous factory. One match is used as before; zero remains AWT164. Also make ReleaseHookDependency_OutlivesTheRelease_InReverseCreationOrder actually exercise the release capture. PooledBuffer previously took the Pool in its constructor, which forced Pool-first creation on its own and would pass even if the capture ordering were wrong. Drop that constructor dependency so the reverse-teardown order is produced solely by the release capture resolving (and first-constructing, and queuing the release of) the Pool before the buffer's own release is enqueued - verified against the generated code. --- Docs/pages/09-diagnostics.md | 18 +++ .../AnalyzerReleases.Unshipped.md | 1 + .../AwaitenGenerator.Production.cs | 16 ++- .../Awaiten.SourceGenerators/Diagnostics.cs | 14 +++ ...osticTests.Awt190AmbiguousLifecycleHook.cs | 104 ++++++++++++++++++ Tests/Awaiten.Tests/LifecycleHookTests.cs | 14 ++- 6 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 8a5ed483..2ffbd94c 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -463,6 +463,24 @@ public static partial class CoffeeShop } ``` +### AWT190 + +:::danger[Error] +A lifecycle hook (`OnActivated` / `OnRelease`) names an overloaded method, so the container cannot choose which one to call. +::: + +The container reaches a hook by simple name, so two accepting overloads leave the choice (and the graph dependencies the extra parameters resolve) order-dependent. Give the hook a unique name, exactly as a factory method must be unambiguous. + +```csharp +[Container] +[Singleton(OnActivated = nameof(Calibrate))] +public static partial class CoffeeShop +{ + private static void Calibrate(EspressoMachine machine) { } + private static void Calibrate(EspressoMachine machine, Settings settings) { } // which one runs? +} +``` + ## Runtime arguments ### AWT113 diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 035c3c2f..05ce6639 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -91,3 +91,4 @@ AWT187 | Awaiten | Warning | A [Scan(As = ScanAs.MatchingInterface)] matched a type implementing several same-named convention interfaces, so it registers under each AWT188 | Awaiten | Warning | A scan match's only exposure interface is inaccessible to the generated container, so the match is not registered AWT189 | Awaiten | Error | A lifecycle hook parameter (after the instance) is marked [Arg], but a hook resolves its parameters from the graph + AWT190 | Awaiten | Error | A lifecycle hook (OnActivated / OnRelease) names an overloaded method, so the container cannot choose which one to call diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index b4b154db..ef398597 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -268,7 +268,8 @@ private static InstanceModel BuildPrebuiltInstance( /// inaccessible from the container is skipped (it cannot be called from the generated code), falling through /// to AWT164. Reports AWT164 and returns /// (null, empty) when no accessible ordinary void method of that name accepts the implementation type - /// as its first parameter. + /// as its first parameter, and AWT190 (also returning + /// (null, empty)) when more than one does, so the choice would be order-dependent. /// private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, @@ -283,6 +284,7 @@ private static (string? Hook, EquatableArray Parameters) Resolve INamedTypeSymbol containerSymbol = context.ContainerSymbol; Compilation compilation = context.Compilation; + List matches = new(); foreach (ISymbol member in AccessibleMembers(info.Origin ?? containerSymbol, hookName)) { // A module hook must also be accessible from the generated container (its own private members are @@ -291,12 +293,20 @@ private static (string? Hook, EquatableArray Parameters) Resolve && compilation.HasImplicitConversion(info.Symbol, method.Parameters[0].Type) && (info.Origin is null || compilation.IsSymbolAccessibleWithin(method, containerSymbol))) { - return (QualifiedHook(info, hookName), ClassifyHookParameters(method, info, context)); + matches.Add(method); } } + if (matches.Count == 1) + { + return (QualifiedHook(info, hookName), ClassifyHookParameters(matches[0], info, context)); + } + + // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick + // between). Either way no hook is emitted - the error fails the build, and picking one arbitrarily would only + // add a confusing secondary diagnostic from the parameters of the guessed overload. context.Diagnostics.Add(new DiagnosticInfo( - Diagnostics.InvalidLifecycleHook, + matches.Count == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, info.Location, new EquatableArray([Display(info.OwningServiceOrImpl), hookName, DescribeOwner(info),]))); return (null, default); diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index ad31dcfd..da013374 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1330,4 +1330,18 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// An OnActivated / OnRelease registration names a method that is overloaded: more than one + /// accessible static void method of that name accepts the implementation type as its first parameter, + /// so the container's choice of hook (and of the graph dependencies its remaining parameters resolve) would + /// be order-dependent. Mirrors AWT112 for factory methods. + /// + public static readonly DiagnosticDescriptor AmbiguousLifecycleHook = new( + "AWT190", + "Ambiguous lifecycle hook", + "'{0}' has an ambiguous lifecycle hook: {2} has more than one accessible method '{1}' accepting the instance; the container cannot choose one. Give the hook method a unique name.", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs new file mode 100644 index 00000000..8761fe6b --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs @@ -0,0 +1,104 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt190AmbiguousLifecycleHook + { + [Fact] + public async Task ReportsWhenTwoOverloadsBothAcceptTheInstance() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + public sealed class Settings { } + + [Container] + [Singleton] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service) { } + private static void Started(Service service, Settings settings) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT190"))).IsTrue() + .Because("two accessible methods named Started accept the instance, so the container cannot choose one"); + } + + [Fact] + public async Task ReportsWhenOverloadsDifferOnlyInTheInstanceParameterType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + + [Container] + [Singleton(OnRelease = nameof(Stopping))] + public static partial class MyContainer + { + private static void Stopping(Service service) { } + private static void Stopping(object instance) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT190"))).IsTrue() + .Because("both the exact and the object overload accept the instance, so the release hook choice is order-dependent"); + } + + [Fact] + public async Task DoesNotReportWhenOnlyOneOverloadAcceptsTheInstance() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + + [Container] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service) { } + private static void Started(int unrelated) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT190") || d.Contains("AWT164"))).IsFalse() + .Because("the int overload does not accept the instance, so there is exactly one usable hook and no ambiguity"); + } + + [Fact] + public async Task DoesNotReportWhenTheSameNameServesDistinctRegistrationsOneMatchEach() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Alpha { } + public sealed class Beta { } + + [Container] + [Singleton(OnActivated = nameof(Started))] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(object instance) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT190"))).IsFalse() + .Because("a single shared object hook is one match per registration, so neither registration is ambiguous"); + } + } +} diff --git a/Tests/Awaiten.Tests/LifecycleHookTests.cs b/Tests/Awaiten.Tests/LifecycleHookTests.cs index c1c9deba..b97f28d6 100644 --- a/Tests/Awaiten.Tests/LifecycleHookTests.cs +++ b/Tests/Awaiten.Tests/LifecycleHookTests.cs @@ -267,8 +267,10 @@ public async Task ReleaseHookDependency_OutlivesTheRelease_InReverseCreationOrde Probe.Reset(); using (PoolContainer.Root container = new()) { - // PooledBuffer takes the Pool in its constructor, so the Pool is created first and, drained in reverse - // creation order, is released last - it is still alive when the buffer's release hook uses it. + // PooledBuffer has no constructor dependency on Pool: resolving it queues the buffer's release, and the + // release capture first-constructs the singleton Pool (queuing Pool's own release) before the buffer's + // release is enqueued. Reverse creation-order teardown then runs the buffer's release before Pool's, so + // the captured Pool is still alive when the buffer's release uses it. container.Resolve(); } @@ -475,9 +477,11 @@ public sealed class Pool public sealed class PooledBuffer { - // Takes the Pool in its constructor so the Pool is created first; reverse-order release then keeps the Pool - // alive until after the buffer's release hook has used it. - public PooledBuffer(Pool pool) => _ = pool; + // Deliberately no constructor dependency on Pool: the Pool is reached only through the release hook's + // parameter. That makes the reverse-creation-order teardown depend solely on the release capture resolving + // (and so first-constructing, and queuing the release of) the Pool before the buffer's own release is + // queued - the exact mechanism ReleaseHookDependency_OutlivesTheRelease_InReverseCreationOrder exercises. A + // constructor dependency would force the Pool-first order regardless, hiding a broken capture ordering. } [Container] From 065d16bfaf9271aa5e250f4d79c3b8ac6096c69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 13:10:47 +0200 Subject: [PATCH 4/6] fix: report key misuse on lifecycle hook parameters ClassifyHookParameters mirrored the constructor/factory classification pipeline but omitted its three key-misuse reporters, so a mistake on a hook parameter that is rejected on a constructor parameter went undiagnosed: a [FromKey] whose constant is of an unsupported key type (AWT170), a synthesized keyed collection requested under an unsupported key type (AWT159), and a [FromKey] on a synthesized keyed collection (AWT160). Run all three from ClassifyHookParameters at the pipeline positions the constructor path uses - AWT170 straight after classification, AWT159/AWT160 after SuppressRegisteredCollectionSynthesis so an explicitly registered dictionary rewritten to Direct is not reported. AWT170 is reported after the [Arg] rejection rather than before it, so a hook parameter already rejected with AWT189 does not collect a second diagnostic. Cover each with a hook-parameter test. Also stop HookParameters() allocating on every call. It is invoked from every dependency-walking pass, several of them per-instance loops on the analyzer's per-keystroke path, and EquatableArray.AsArray() is already zero-alloc for an empty array. Return a backing array directly when either hook contributes no parameters (the overwhelmingly common case) and build a new array only when both do. A cached field is deliberately not used: record equality compares all instance fields, so a lazily populated cache would make two equal models compare unequal and break incremental-generation caching. Document that [RequestingType] is factory-only, so like a constructor parameter it is not honored on a hook parameter: a hook is invoked by the container for an instance rather than requested by a consumer, so there is no requesting consumer whose typeof(...) it could receive. --- .../AwaitenGenerator.Production.cs | 22 ++++- .../Entities/InstanceModel.cs | 19 +++- .../DiagnosticTests.HookParameterKeyMisuse.cs | 98 +++++++++++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.HookParameterKeyMisuse.cs diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index ef398597..3a190148 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -317,9 +317,15 @@ private static (string? Hook, EquatableArray Parameters) Resolve /// dependencies the container resolves and supplies to the hook, mirroring the constructor/factory pipeline /// in (contextual binding, registered-collection suppression, variance and /// the [ImportServices] fall-through), and reporting AWT101 - /// for an unregistered one. A parameter marked [Arg] is rejected with - /// AWT189 and dropped: runtime arguments flow only through a - /// Func<…> factory into [Arg] constructor parameters, and a hook has no such call site. + /// for an unregistered one. The same key-misuse diagnostics as a constructor parameter apply: an unsupported + /// [FromKey] constant type (AWT170), and on a synthesized + /// keyed collection an unsupported key type (AWT159) + /// or a stray [FromKey] (AWT160). A parameter + /// marked [Arg] is rejected with AWT189 and dropped: + /// runtime arguments flow only through a Func<…> factory into [Arg] constructor parameters, + /// and a hook has no such call site. [RequestingType] is a factory-only feature (a hook is invoked by the + /// container for an instance, not requested by a consumer), so like a constructor parameter it is not honored + /// here and a System.Type so marked surfaces as an unregistered dependency (AWT101). /// private static EquatableArray ClassifyHookParameters(IMethodSymbol hook, ImplInfo info, BuildContext context) { @@ -339,8 +345,18 @@ private static EquatableArray ClassifyHookParameters(IMethodSymb continue; } + // AWT170: a [FromKey] whose constant is of an unsupported key type, reported exactly as for a constructor + // parameter (a dropped [Arg] above never reaches here, so it is not doubly reported). + ReportUnsupportedFromKey(parameter.GetAttributes(), parameterModel.Location ?? info.Location, DisplayInstance(info.ImplementationType), context.Diagnostics); + parameterModel = RedirectContextualBinding(parameterModel, info, context.ServiceToImpl, context.ConsumedConditionals); parameterModel = SuppressRegisteredCollectionSynthesis(parameterModel, parameter.Type, context.ServiceToImpl); + + // AWT159/AWT160: keyed-collection misuse (an unsupported key type, or a [FromKey] on a synthesized keyed + // collection), reported only for a dictionary that stayed synthesized past the suppression above. + ReportUnsupportedKeyedCollectionKey(parameterModel, parameter.Type, info, context.ServiceToImpl, context.Diagnostics); + ReportFromKeyOnKeyedCollection(parameterModel, parameter.Type, info, context.Diagnostics); + parameterModel = RedirectVariance(parameterModel, parameter, context.ServiceToImpl, context.Variance); RecordRequestedCollectionElement(parameterModel, parameter, context.Variance); diff --git a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs index c3df7142..79a68b13 100644 --- a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs @@ -103,8 +103,23 @@ public string[] ArgTypes() => ConstructorParameters.AsArray() /// resolved eagerly at construction (the activation dependency inline at the call, the release dependency /// captured by value). Every dependency-walking pass that visits must /// also visit these, or a hook dependency escapes cycle/captive/async/argument analysis and the emitted - /// infrastructure it needs. + /// infrastructure it needs. The common case (no hook parameters, or only one hook contributing any) returns a + /// backing array without allocating; a new array is built only when both hooks contribute parameters. /// public ParameterModel[] HookParameters() - => ActivationParameters.AsArray().Concat(ReleaseParameters.AsArray()).ToArray(); + { + ParameterModel[] activation = ActivationParameters.AsArray(); + ParameterModel[] release = ReleaseParameters.AsArray(); + if (release.Length == 0) + { + return activation; + } + + if (activation.Length == 0) + { + return release; + } + + return activation.Concat(release).ToArray(); + } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.HookParameterKeyMisuse.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.HookParameterKeyMisuse.cs new file mode 100644 index 00000000..7850ce14 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.HookParameterKeyMisuse.cs @@ -0,0 +1,98 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + /// + /// A lifecycle hook's graph-resolved parameters are classified exactly like constructor parameters, so the + /// same key-misuse diagnostics apply to them: an unsupported [FromKey] constant (AWT170), an + /// unsupported keyed-collection key type (AWT159), and a [FromKey] on a synthesized keyed collection + /// (AWT160). + /// + public class HookParameterKeyMisuse + { + [Fact] + public async Task FromKeyOfUnsupportedTypeOnAHookParameter_ReportsAwt170() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IChannel { } + public sealed class Fast : IChannel { } + public sealed class Service { } + + [Container] + [Singleton(Key = "fast")] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, [FromKey(5)] IChannel channel) { } + } + """); + + // int is not a supported key type for a [FromKey], and a hook parameter is classified like a constructor + // parameter, so it is rejected rather than silently treated as unkeyed. + await That(result.Diagnostics.Any(d => d.Contains("AWT170"))).IsTrue() + .Because("a hook parameter's [FromKey] is validated exactly like a constructor parameter's"); + } + + [Fact] + public async Task KeyedCollectionHookParameterWithUnsupportedKeyType_ReportsAwt159() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Collections.Generic; + + namespace MyCode; + + public interface IChannel { } + public sealed class Fast : IChannel { } + public sealed class Service { } + + [Container] + [Singleton(Key = "fast")] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, IReadOnlyDictionary channels) { } + } + """); + + // int is neither string nor an enum, so a keyed dictionary cannot synthesize under it (AWT159), for a hook + // parameter as for a constructor one. + await That(result.Diagnostics.Any(d => d.Contains("AWT159"))).IsTrue() + .Because("a keyed-collection hook parameter is validated like a constructor parameter's"); + } + + [Fact] + public async Task FromKeyOnASynthesizedKeyedDictionaryHookParameter_ReportsAwt160() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Collections.Generic; + + namespace MyCode; + + public interface IChannel { } + public sealed class Fast : IChannel { } + public sealed class Service { } + + [Container] + [Singleton(Key = "fast")] + [Singleton(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, [FromKey("fast")] IReadOnlyDictionary channels) { } + } + """); + + // A [FromKey] cannot select within the synthesized dictionary, so it is rejected (AWT160) on a hook + // parameter exactly as on a constructor one. + await That(result.Diagnostics.Any(d => d.Contains("AWT160"))).IsTrue() + .Because("a [FromKey] on a synthesized keyed-collection hook parameter is rejected like a constructor parameter's"); + } + } +} From 5afae811f18b6a50ef4b2706e1cfac006fbcfe25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 15:06:59 +0200 Subject: [PATCH 5/6] feat: reject Func/Lazy parameters on release hooks (AWT191) A release hook's dependencies are captured at construction, but a Func/Lazy parameter captures only a resolver delegate, and the hook runs during its owner's teardown, after __disposed is set - so invoking the delegate there throws ObjectDisposedException every time, making the parameter unusable at the only moment the hook runs. Report AWT191 (error) for the four deferred-delegate kinds (Func, Lazy, FuncTask, LazyTask, covering their Task and Owned forms) on OnRelease hook parameters. An OnActivated hook runs while the owner is alive, so its Func/Lazy parameters remain allowed. IAsyncEnumerable needs no rejection: it materializes its members eagerly at capture time. Unlike a rejected [Arg] (AWT189), the parameter is kept rather than dropped: it resolves like any graph dependency, so the emitted capture and every downstream dependency-walking pass stay coherent while the error fails the build. The AWT118 analyzer test's fixture was a release hook holding a Func over a disposable transient - exactly the shape AWT191 now rejects, and the analyzer harness requires its source to compile. Move the scenario to an activation hook, which is the surviving hook-parameter shape that walk covers (a root-owned singleton's activation hook can invoke its Func, each call tracking a fresh disposable on the root), and update the analyzer comment that cited the release-capture rationale. Also close the runtime coverage gaps on the hook-parameter emit paths: an async-initialized activation hook dependency is awaited (initialized) before the hook runs on the async caching path, an async release capture is awaited at construction on the async fresh path, and a scoped instance's release capture goes through the cached release-registration path and runs when its scope is disposed. --- Docs/pages/09-diagnostics.md | 18 +++ Docs/pages/lifetime/05-lifecycle-hooks.md | 2 +- .../AnalyzerReleases.Unshipped.md | 1 + .../AwaitenAnalyzer.cs | 7 +- .../AwaitenGenerator.Production.cs | 29 +++- .../Awaiten.SourceGenerators/Diagnostics.cs | 16 ++ ...sticTests.Awt118RootAccumulatingFactory.cs | 10 +- ...ests.Awt191ReleaseHookDeferredParameter.cs | 141 ++++++++++++++++++ Tests/Awaiten.Tests/LifecycleHookTests.cs | 109 ++++++++++++++ 9 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt191ReleaseHookDeferredParameter.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 2ffbd94c..6bc61cbf 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -481,6 +481,24 @@ public static partial class CoffeeShop } ``` +### AWT191 + +:::danger[Error] +An `OnRelease` hook parameter is a `Func`/`Lazy` relationship, which would defer resolution past the owner's teardown. +::: + +A release dependency is captured at construction, but a `Func` or `Lazy` captures only a resolver delegate. The hook runs while its owner is being disposed, so invoking the delegate there always throws. Take the dependency directly instead: it is resolved at construction and, released in reverse creation order, still alive when the hook uses it. An `OnActivated` hook may take `Func`/`Lazy` parameters freely, since it runs while the owner is alive. + +```csharp +[Container] +[Singleton] +[Transient(OnRelease = nameof(ReturnToPool))] +public static partial class CoffeeShop +{ + private static void ReturnToPool(Buffer buffer, Func pool) { } // pool() would throw during teardown; take BufferPool directly +} +``` + ## Runtime arguments ### AWT113 diff --git a/Docs/pages/lifetime/05-lifecycle-hooks.md b/Docs/pages/lifetime/05-lifecycle-hooks.md index 644bfb6d..230876a9 100644 --- a/Docs/pages/lifetime/05-lifecycle-hooks.md +++ b/Docs/pages/lifetime/05-lifecycle-hooks.md @@ -40,7 +40,7 @@ public static partial class CoffeeShop An activation dependency is resolved inline as the hook is called. A release dependency is resolved at construction and captured by value into the queued closure, so the hook holds the instance it was queued for even if a later resolve fails and rolls back. Reverse creation-order teardown keeps a captured dependency (a singleton pool, say) alive until after the release that uses it has run. -Hook parameters participate in the graph like any other dependency: an unregistered one is [AWT101](../diagnostics#awt101), and they are covered by cycle, captive-dependency and async-taint analysis. A hook parameter cannot be a runtime `[Arg]` (there is no `Func<…>` call site to supply one), which is [AWT189](../diagnostics#awt189). +Hook parameters participate in the graph like any other dependency: an unregistered one is [AWT101](../diagnostics#awt101), and they are covered by cycle, captive-dependency and async-taint analysis. A hook parameter cannot be a runtime `[Arg]` (there is no `Func<…>` call site to supply one), which is [AWT189](../diagnostics#awt189). A release hook parameter also cannot be a `Func` or `Lazy`: those capture a resolver delegate rather than a value, and by the time the hook runs the owner is already tearing down, so invoking the delegate would throw. That is [AWT191](../diagnostics#awt191); an activation hook may take them freely. ## Ordering and failure diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 05ce6639..18e42fdf 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -92,3 +92,4 @@ AWT188 | Awaiten | Warning | A scan match's only exposure interface is inaccessible to the generated container, so the match is not registered AWT189 | Awaiten | Error | A lifecycle hook parameter (after the instance) is marked [Arg], but a hook resolves its parameters from the graph AWT190 | Awaiten | Error | A lifecycle hook (OnActivated / OnRelease) names an overloaded method, so the container cannot choose which one to call + AWT191 | Awaiten | Error | An OnRelease hook parameter is a Func/Lazy relationship, which would defer resolution past the owner's teardown diff --git a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs index e089845a..d734cef7 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs @@ -225,9 +225,10 @@ private static void AddAccumulatingFuncs( DiagnosticDescriptor descriptor = strict ? Diagnostics.RootAccumulatingFactoryStrict : Diagnostics.RootAccumulatingFactory; DiagnosticSeverity? severity = strict ? DiagnosticSeverity.Error : null; - // A lifecycle hook's Func parameter is rooted too - a release hook captures it by value into the owner's - // teardown closure - so an accumulating fresh-disposable Func reached through one is reported like a - // constructor parameter's. + // A lifecycle hook's Func parameter is rooted too - an activation hook of a root-owned instance can + // invoke it, each call tracking a fresh disposable on the root - so an accumulating fresh-disposable Func + // reached through one is reported like a constructor parameter's. (A release hook's Func is rejected + // outright by AWT191, so only activation hooks reach here with one in practice.) InstanceModel holder = graph.Instances[node]; foreach (ParameterModel parameter in holder.ConstructorParameters.AsArray().Concat(holder.HookParameters())) { diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 3a190148..47e72c2e 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -93,9 +93,10 @@ partial class AwaitenGenerator // Lifecycle hooks (AWT164 when a named member is not a usable static void M(TImplementation, …)). Applied to // constructed and factory-produced instances - the ones the container owns; a pre-built Instance returns // above (the caller owns it, so activation/release do not apply). A hook's parameters after the instance are - // graph dependencies (AWT101 when unregistered, AWT189 for a runtime [Arg]), resolved like a constructor's. - (string? onActivated, EquatableArray activationParameters) = ResolveHook(info, info.OnActivated, context); - (string? onRelease, EquatableArray releaseParameters) = ResolveHook(info, info.OnRelease, context); + // graph dependencies (AWT101 when unregistered, AWT189 for a runtime [Arg], AWT191 for a Func/Lazy on the + // release hook), resolved like a constructor's. + (string? onActivated, EquatableArray activationParameters) = ResolveHook(info, info.OnActivated, release: false, context); + (string? onRelease, EquatableArray releaseParameters) = ResolveHook(info, info.OnRelease, release: true, context); return new InstanceModel( info.ImplementationType, @@ -274,6 +275,7 @@ private static InstanceModel BuildPrebuiltInstance( private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, string? hookName, + bool release, BuildContext context) { if (hookName is null) @@ -299,7 +301,7 @@ private static (string? Hook, EquatableArray Parameters) Resolve if (matches.Count == 1) { - return (QualifiedHook(info, hookName), ClassifyHookParameters(matches[0], info, context)); + return (QualifiedHook(info, hookName), ClassifyHookParameters(matches[0], info, release, context)); } // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick @@ -323,11 +325,16 @@ private static (string? Hook, EquatableArray Parameters) Resolve /// or a stray [FromKey] (AWT160). A parameter /// marked [Arg] is rejected with AWT189 and dropped: /// runtime arguments flow only through a Func<…> factory into [Arg] constructor parameters, - /// and a hook has no such call site. [RequestingType] is a factory-only feature (a hook is invoked by the + /// and a hook has no such call site. On a hook a Func/Lazy parameter + /// is rejected with AWT191: the capture holds only + /// a resolver delegate, and the hook runs during the owner's teardown, when the resolvers refuse - the + /// deferred value could never produce its target. It is kept (it resolves like any graph dependency), so the + /// emitted capture and every downstream pass stay coherent; the error already fails the build. + /// [RequestingType] is a factory-only feature (a hook is invoked by the /// container for an instance, not requested by a consumer), so like a constructor parameter it is not honored /// here and a System.Type so marked surfaces as an unregistered dependency (AWT101). /// - private static EquatableArray ClassifyHookParameters(IMethodSymbol hook, ImplInfo info, BuildContext context) + private static EquatableArray ClassifyHookParameters(IMethodSymbol hook, ImplInfo info, bool release, BuildContext context) { List parameters = new(); foreach (IParameterSymbol parameter in hook.Parameters.Skip(1)) @@ -345,6 +352,16 @@ private static EquatableArray ClassifyHookParameters(IMethodSymb continue; } + // AWT191: a release hook's Func/Lazy parameter (any of the four deferred-delegate kinds, covering their + // Task and Owned forms) captures a resolver delegate that is dead by the time the hook runs. + if (release && parameterModel.Kind is DependencyKind.Func or DependencyKind.Lazy or DependencyKind.FuncTask or DependencyKind.LazyTask) + { + context.Diagnostics.Add(new DiagnosticInfo( + Diagnostics.ReleaseHookDeferredParameter, + parameterModel.Location ?? info.Location, + new EquatableArray([parameter.Name, DisplayInstance(info.ImplementationType),]))); + } + // AWT170: a [FromKey] whose constant is of an unsupported key type, reported exactly as for a constructor // parameter (a dropped [Arg] above never reaches here, so it is not doubly reported). ReportUnsupportedFromKey(parameter.GetAttributes(), parameterModel.Location ?? info.Location, DisplayInstance(info.ImplementationType), context.Diagnostics); diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index da013374..180971e5 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1344,4 +1344,20 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// An OnRelease hook parameter is a Func/Lazy relationship (including their + /// Task and Owned forms). A release dependency is captured at construction, but a + /// Func<T> or Lazy<T> captures only a resolver delegate, and the hook runs during + /// its owner's teardown, when the resolvers refuse (ObjectDisposedException) - the deferred value + /// could never produce its target. An activation hook may take them freely (it runs while the owner is + /// alive), so this applies to release hooks only. + /// + public static readonly DiagnosticDescriptor ReleaseHookDeferredParameter = new( + "AWT191", + "Release hook parameter cannot defer resolution", + "the parameter '{0}' of the OnRelease lifecycle hook for '{1}' defers resolution behind a Func/Lazy, but a release hook runs during its owner's teardown, when the container no longer resolves; take the dependency directly, so it is captured at construction", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs index c0b6760f..fee24454 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs @@ -300,7 +300,7 @@ await That(diagnostics).Contains("*AWT118*").AsWildcard() } [Fact] - public async Task ReportsWhenASingletonReleaseHookHoldsAFuncOverADisposableTransient() + public async Task ReportsWhenASingletonActivationHookHoldsAFuncOverADisposableTransient() { string[] diagnostics = await Analyzer.Run(""" using Awaiten; @@ -313,15 +313,17 @@ public sealed class Depot { } [Container] [Transient] - [Singleton(OnRelease = nameof(Drain))] + [Singleton(OnActivated = nameof(Started))] public static partial class MyContainer { - private static void Drain(Depot depot, Func tools) { } + private static void Started(Depot depot, Func tools) { } } """); + // A release hook's Func is rejected outright (AWT191), so the activation hook is the surviving + // hook-parameter shape this walk covers. await That(diagnostics.Any(d => d.Contains("AWT118"))).IsTrue() - .Because("a singleton release hook captures its Func over a disposable transient into the root's teardown closure, so its instances accumulate on the root like a constructor-held Func"); + .Because("a root-owned singleton's activation hook can invoke its Func over a disposable transient, each call tracking a fresh disposable on the root like a constructor-held Func"); } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt191ReleaseHookDeferredParameter.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt191ReleaseHookDeferredParameter.cs new file mode 100644 index 00000000..e941f787 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt191ReleaseHookDeferredParameter.cs @@ -0,0 +1,141 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + /// + /// A release hook's dependencies are captured at construction, but a Func<T>/Lazy<T> + /// parameter captures only a resolver delegate, and the hook runs during its owner's teardown, when the + /// resolvers throw ObjectDisposedException - so the deferred value could never produce its target. + /// AWT191 rejects the four deferred-delegate kinds on release hooks; an activation hook (which runs while the + /// owner is alive) may take them freely. + /// + public class Awt191ReleaseHookDeferredParameter + { + [Fact] + public async Task ReportsForAFuncReleaseHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public sealed class Tool { } + public sealed class Service { } + + [Container] + [Transient] + [Transient(OnRelease = nameof(Released))] + public static partial class MyContainer + { + private static void Released(Service service, Func tool) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT191"))).IsTrue() + .Because("a Func release capture holds a resolver delegate that is dead by the time the hook runs at teardown"); + } + + [Fact] + public async Task ReportsForALazyReleaseHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public sealed class Tool { } + public sealed class Service { } + + [Container] + [Singleton] + [Transient(OnRelease = nameof(Released))] + public static partial class MyContainer + { + private static void Released(Service service, Lazy tool) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT191"))).IsTrue() + .Because("an unmaterialized Lazy release capture defers resolution past the owner's teardown, like a Func"); + } + + [Fact] + public async Task ReportsForAFuncTaskReleaseHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Tool { } + public sealed class Service { } + + [Container] + [Transient] + [Transient(OnRelease = nameof(Released))] + public static partial class MyContainer + { + private static void Released(Service service, Func> tool) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT191"))).IsTrue() + .Because("the async factory form defers resolution exactly like the synchronous Func"); + } + + [Fact] + public async Task DoesNotReportForAFuncActivationHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public sealed class Tool { } + public sealed class Service { } + + [Container] + [Transient] + [Transient(OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(Service service, Func tool) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT191"))).IsFalse() + .Because("an activation hook runs while the owner is alive, so its Func parameter is invokable"); + } + + [Fact] + public async Task DoesNotReportForADirectReleaseHookParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Pool { } + public sealed class Service { } + + [Container] + [Singleton] + [Transient(OnRelease = nameof(Released))] + public static partial class MyContainer + { + private static void Released(Service service, Pool pool) { } + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT191"))).IsFalse() + .Because("a direct release dependency is resolved at construction and captured by value, which is the supported shape"); + } + } +} diff --git a/Tests/Awaiten.Tests/LifecycleHookTests.cs b/Tests/Awaiten.Tests/LifecycleHookTests.cs index b97f28d6..4317466e 100644 --- a/Tests/Awaiten.Tests/LifecycleHookTests.cs +++ b/Tests/Awaiten.Tests/LifecycleHookTests.cs @@ -278,6 +278,60 @@ await That(Probe.Log.IndexOf("released:PooledBuffer") < Probe.Log.IndexOf("relea .Because("a release hook's captured dependency must still be alive when the release runs"); } + [Fact] + public async Task AsyncActivationHookDependency_IsInitializedWhenTheHookRuns() + { + Probe.Reset(); + using AsyncActivationDependencyContainer.Root container = new(); + + // The hook's Gauge parameter is async-initialized, so it taints AsyncMachine and forces the async path, + // which awaits the dependency's initialization inline in the hook call. + AsyncMachine machine = await container.ResolveAsync(TestContext.Current.CancellationToken); + AsyncGauge gauge = await container.ResolveAsync(TestContext.Current.CancellationToken); + + await That(machine.Gauge).IsSameAs(gauge) + .Because("the activation hook's extra parameter resolves from the graph on the async path too"); + await That(Probe.Log).Contains("activated-with-initialized-gauge") + .Because("the async path awaits the hook dependency's initialization before invoking the hook"); + } + + [Fact] + public async Task AsyncReleaseHookDependency_IsCapturedInitialized() + { + Probe.Reset(); + using (AsyncReleaseDependencyContainer.Root container = new()) + { + // The release capture on the async-initialized AsyncGauge taints AsyncBuffer, so it resolves + // asynchronously; the capture is awaited at construction, before the release closure is queued. + await container.ResolveAsync(TestContext.Current.CancellationToken); + await That(Probe.Log).DoesNotContain("released-with-initialized-gauge"); + } + + await That(Probe.Log).Contains("released-with-initialized-gauge") + .Because("the release hook received the dependency that was captured, already initialized, at construction"); + } + + [Fact] + public async Task ScopedReleaseHook_ReceivesAGraphDependency_WhenTheScopeIsDisposed() + { + Probe.Reset(); + using ScopedReleaseDependencyContainer.Root container = new(); + Pool pool = container.Resolve(); + ScopedBuffer buffer; + using (var scope = container.CreateScope()) + { + buffer = scope.Resolve(); + + // Nothing has been released while the scope is alive. + await That(pool.Returned).DoesNotContain(buffer); + } + + // The scoped instance's release ran with the scope's disposal (the cached release-registration path), + // handing the hook its graph-resolved singleton Pool. + await That(pool.Returned).Contains(buffer) + .Because("a scoped instance's release capture resolves from the graph and runs when its scope is disposed"); + } + public sealed class Alpha; public sealed class Beta; @@ -508,4 +562,59 @@ private static void ReturnToPool(PooledBuffer buffer, Pool pool) private static void ReleasePool(Pool pool) => Probe.Log.Add("released:Pool"); } + + public sealed class AsyncGauge : IAsyncInitializable + { + public bool Initialized { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Initialized = true; + return Task.CompletedTask; + } + } + + public sealed class AsyncMachine + { + public AsyncGauge? Gauge { get; set; } + } + + public sealed class AsyncBuffer; + + public sealed class ScopedBuffer; + + [Container] + [Singleton] + [Singleton(OnActivated = nameof(Attach))] + public static partial class AsyncActivationDependencyContainer + { + // The async-initialized Gauge parameter taints AsyncMachine, so the hook argument is resolved (and its + // initialization awaited) on the async construction path. + private static void Attach(AsyncMachine machine, AsyncGauge gauge) + { + machine.Gauge = gauge; + Probe.Log.Add(gauge.Initialized ? "activated-with-initialized-gauge" : "activated-with-uninitialized-gauge"); + } + } + + [Container] + [Singleton] + [Transient(OnRelease = nameof(ReturnAsyncBuffer))] + public static partial class AsyncReleaseDependencyContainer + { + // The async-initialized Gauge is captured (awaited) at construction; the hook observes the state it was + // captured in when the container tears down. + private static void ReturnAsyncBuffer(AsyncBuffer buffer, AsyncGauge gauge) + => Probe.Log.Add(gauge.Initialized ? "released-with-initialized-gauge" : "released-with-uninitialized-gauge"); + } + + [Container] + [Singleton] + [Scoped(OnRelease = nameof(ReturnScopedBuffer))] + public static partial class ScopedReleaseDependencyContainer + { + // A scoped instance queues its release on the scope through the cached release-registration path; the hook's + // Pool parameter resolves the root-owned singleton through the scope. + private static void ReturnScopedBuffer(ScopedBuffer buffer, Pool pool) => pool.Return(buffer); + } } From b50946fad26852cfb39745f212177c505ca1fcd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 15:36:59 +0200 Subject: [PATCH 6/6] refactor: collapse the dependency-walking triple loops into WalkedDependencies() Routing lifecycle hook parameters through every dependency-walking pass added a third sibling foreach (constructor parameters, injected [Inject] members, hook parameters) to each of them, pushing DetectSynchronousAsyncResolution, DetectSynchronousAsyncCollection and ExternalDependencies over Sonar's cognitive-complexity threshold of 15 (16, 17 and 19). Add InstanceModel.WalkedDependencies(), enumerating the three groups in the order the passes already visited them (constructor parameters, member dependencies, hook parameters), and collapse each pass to a single loop over it - so diagnostic order and ExternalDependencies' first-seen dedup order are unchanged. DetectOwnedOverRequestingTypeFactory was below the threshold but shares the identical shape, so it is converted too rather than left as the one hand-rolled copy of the pattern. Passes that treat the groups differently (deferred members in BuildEdges, the [Arg]-index-sensitive walks) keep enumerating them separately, as the helper's doc note spells out. --- .../AwaitenGenerator.Analysis.cs | 50 ++++--------------- .../Entities/InstanceModel.cs | 12 +++++ .../Sources/Sources.Dispatch.cs | 23 ++------- 3 files changed, 26 insertions(+), 59 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs index 8321c8df..aba7f30b 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs @@ -449,19 +449,9 @@ private static void DetectSynchronousAsyncResolution( { for (int i = 0; i < instances.Count; i++) { - foreach (ParameterModel parameter in instances[i].ConstructorParameters.AsArray()) - { - CheckDependency(i, parameter); - } - - foreach (MemberModel member in instances[i].InjectedMembers.AsArray()) - { - CheckDependency(i, member.Dependency); - } - - // A hook's synchronous relationship parameter resolves its target without awaiting initialization, - // exactly like a constructor parameter's, so it is checked the same way. - foreach (ParameterModel parameter in instances[i].HookParameters()) + // A constructor parameter, an injected [Inject] member and a lifecycle hook parameter all resolve a + // synchronous relationship's target without awaiting initialization, so all are checked alike. + foreach (ParameterModel parameter in instances[i].WalkedDependencies()) { CheckDependency(i, parameter); } @@ -528,19 +518,9 @@ private static void DetectOwnedOverRequestingTypeFactory( { for (int i = 0; i < instances.Count; i++) { - foreach (ParameterModel parameter in instances[i].ConstructorParameters.AsArray()) - { - CheckDependency(i, parameter); - } - - foreach (MemberModel member in instances[i].InjectedMembers.AsArray()) - { - CheckDependency(i, member.Dependency); - } - - // A hook's Owned-family parameter has the same structural incompatibility with a requesting-type - // factory as a constructor parameter's (the emit path has no owned form for it), so it is checked too. - foreach (ParameterModel parameter in instances[i].HookParameters()) + // A constructor parameter, an injected [Inject] member and a lifecycle hook parameter share the same + // structural incompatibility (the emit path has no owned form for any of them), so all are checked alike. + foreach (ParameterModel parameter in instances[i].WalkedDependencies()) { CheckDependency(i, parameter); } @@ -603,20 +583,10 @@ private static void DetectSynchronousAsyncCollection( for (int i = 0; i < instances.Count; i++) { - foreach (ParameterModel parameter in instances[i].ConstructorParameters.AsArray()) - { - CheckCollection(i, parameter); - } - - // An injected [Inject] collection member (deferred or not) is materialized through the same - // synchronous expression as a constructor parameter, so it is checked the same way. - foreach (ParameterModel dependency in instances[i].InjectedMembers.AsArray().Select(member => member.Dependency)) - { - CheckCollection(i, dependency); - } - - // A hook's collection parameter is materialized through the same synchronous expression too. - foreach (ParameterModel parameter in instances[i].HookParameters()) + // A constructor parameter, an injected [Inject] member (deferred or not) and a lifecycle hook + // parameter all materialize a collection through the same synchronous expression, so all are + // checked alike. + foreach (ParameterModel parameter in instances[i].WalkedDependencies()) { CheckCollection(i, parameter); } diff --git a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs index 79a68b13..dfa45809 100644 --- a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using Awaiten.SourceGenerators.Internals; @@ -122,4 +123,15 @@ public ParameterModel[] HookParameters() return activation.Concat(release).ToArray(); } + + /// + /// Every graph dependency this instance resolves: its constructor/factory parameters, its injected + /// [Inject] member dependencies and its lifecycle hook parameters, in that order. For the + /// dependency-walking passes that treat all three groups alike; a pass that distinguishes them (deferred + /// members in the cycle graph, say) enumerates the groups itself. + /// + public IEnumerable WalkedDependencies() + => ConstructorParameters.AsArray() + .Concat(InjectedMembers.AsArray().Select(member => member.Dependency)) + .Concat(HookParameters()); } diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs index c6863a33..3fd30a33 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs @@ -1097,25 +1097,10 @@ private static (string Type, string? Key)[] ExternalDependencies(InstanceModel[] HashSet<(string, string?)> seen = new(); foreach (InstanceModel instance in instances) { - foreach (ParameterModel parameter in instance.ConstructorParameters.AsArray()) - { - if (parameter.Kind == DependencyKind.External && seen.Add((parameter.ServiceType, parameter.Key))) - { - external.Add((parameter.ServiceType, parameter.Key)); - } - } - - foreach (ParameterModel dependency in instance.InjectedMembers.AsArray().Select(member => member.Dependency)) - { - if (dependency.Kind == DependencyKind.External && seen.Add((dependency.ServiceType, dependency.Key))) - { - external.Add((dependency.ServiceType, dependency.Key)); - } - } - - // A lifecycle hook parameter can be an external dependency too, and it is emitted through the same - // __ResolveExternal surface, so it must advertise the type and drive that surface's emission. - foreach (ParameterModel parameter in instance.HookParameters()) + // A constructor parameter, an injected [Inject] member and a lifecycle hook parameter are all emitted + // through the same __ResolveExternal surface, so each advertises its type and drives that surface's + // emission alike. + foreach (ParameterModel parameter in instance.WalkedDependencies()) { if (parameter.Kind == DependencyKind.External && seen.Add((parameter.ServiceType, parameter.Key))) {