diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 302f2bf..6bc61cb 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -448,6 +448,57 @@ 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] +} +``` + +### 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? +} +``` + +### 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 a901864..230876a 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). 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 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 a557005..18e42fd 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -90,3 +90,6 @@ 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 + 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 57626c9..d734cef 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs @@ -225,8 +225,12 @@ 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 - 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()) + 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 c516034..aba7f30 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) { @@ -282,6 +285,15 @@ 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].HookParameters()) + { + AddParameterEdges(parameter, serviceToImpl, implToIndex, serviceMembers, keyedMembers, includeEagerBare, nodeEdges); + } + edges[i] = nodeEdges; } @@ -437,15 +449,12 @@ private static void DetectSynchronousAsyncResolution( { for (int i = 0; i < instances.Count; i++) { - foreach (ParameterModel parameter in instances[i].ConstructorParameters.AsArray()) + // 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); } - - foreach (MemberModel member in instances[i].InjectedMembers.AsArray()) - { - CheckDependency(i, member.Dependency); - } } void CheckDependency(int consumer, ParameterModel parameter) @@ -509,15 +518,12 @@ private static void DetectOwnedOverRequestingTypeFactory( { for (int i = 0; i < instances.Count; i++) { - foreach (ParameterModel parameter in instances[i].ConstructorParameters.AsArray()) + // 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); } - - foreach (MemberModel member in instances[i].InjectedMembers.AsArray()) - { - CheckDependency(i, member.Dependency); - } } void CheckDependency(int consumer, ParameterModel parameter) @@ -577,17 +583,13 @@ private static void DetectSynchronousAsyncCollection( for (int i = 0; i < instances.Count; i++) { - foreach (ParameterModel parameter in instances[i].ConstructorParameters.AsArray()) + // 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); } - - // 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); - } } void CheckCollection(int consumer, ParameterModel dependency) @@ -710,8 +712,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 @@ -841,13 +844,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 767594d..5b08395 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/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index df8793e..47e72c2 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -90,11 +90,13 @@ 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], 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, @@ -117,7 +119,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 +255,140 @@ 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, and AWT190 (also returning + /// (null, empty)) when more than one does, so the choice would be order-dependent. /// - private static string? ResolveHook( - INamedTypeSymbol containerSymbol, + private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, string? hookName, - Compilation compilation, - List diagnostics) + bool release, + BuildContext context) { if (hookName is null) { - return null; + return (null, default); } + 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 // 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); + matches.Add(method); } } - diagnostics.Add(new DiagnosticInfo( - Diagnostics.InvalidLifecycleHook, + if (matches.Count == 1) + { + 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 + // 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( + matches.Count == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, 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. 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. 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, bool release, 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; + } + + // 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); + + 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); + + 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 6281159..180971e 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1315,4 +1315,49 @@ 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); + + /// + /// 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); + + /// + /// 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/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs index 2c72f4b..dfa4580 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; @@ -16,9 +17,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 +43,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 @@ -91,4 +97,41 @@ 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. 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() + { + 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(); + } + + /// + /// 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.Construction.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Construction.cs index fd77451..1330085 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 ae7e2d3..3fd30a3 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Dispatch.cs @@ -1097,21 +1097,16 @@ 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()) + // 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))) { 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)); - } - } } return external.ToArray(); diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs index aa164e8..abcd4e5 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 8753c2c..0047e0e 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.Awt115ParameterizedRequiresFunc.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt115ParameterizedRequiresFunc.cs index 4cd8857..297d096 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 90e43dd..fee2445 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs @@ -298,5 +298,32 @@ 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 ReportsWhenASingletonActivationHookHoldsAFuncOverADisposableTransient() + { + 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(OnActivated = nameof(Started))] + public static partial class MyContainer + { + 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 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.Awt119Awt120SynchronousAsyncResolution.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt119Awt120SynchronousAsyncResolution.cs index a5cae7c..9d5f663 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 9d1148a..8968e61 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.Awt164InvalidLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs index 0aafe42..89903d5 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.Awt176UnconsumedExternalService.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt176UnconsumedExternalService.cs index b86a95a..b199170 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/DiagnosticTests.Awt189HookParameterIsArg.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt189HookParameterIsArg.cs new file mode 100644 index 0000000..ac9ce73 --- /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.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs new file mode 100644 index 0000000..8761fe6 --- /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.SourceGenerators.Tests/DiagnosticTests.Awt191ReleaseHookDeferredParameter.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt191ReleaseHookDeferredParameter.cs new file mode 100644 index 0000000..e941f78 --- /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.SourceGenerators.Tests/DiagnosticTests.HookParameterKeyMisuse.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.HookParameterKeyMisuse.cs new file mode 100644 index 0000000..7850ce1 --- /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"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs b/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs index 843fc72..e374134 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 996c81d..8e9d99e 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"); + } } diff --git a/Tests/Awaiten.Tests/LifecycleHookTests.cs b/Tests/Awaiten.Tests/LifecycleHookTests.cs index 4091f59..4317466 100644 --- a/Tests/Awaiten.Tests/LifecycleHookTests.cs +++ b/Tests/Awaiten.Tests/LifecycleHookTests.cs @@ -226,6 +226,112 @@ 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 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(); + } + + 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"); + } + + [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; @@ -406,4 +512,109 @@ 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 + { + // 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] + [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"); + } + + 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); + } }